fix: add tier columns to users table, remove app-specific seeder
- Add tier and tier_expires_at columns to users table in create migration - Remove SystemUserSeeder (moved to host.uk.com as it uses app-specific models) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
5919bca336
commit
1f1c8d0496
62 changed files with 8121 additions and 2042 deletions
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Api\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Mod\Api\Services\ApiKeyService;
|
||||
|
||||
/**
|
||||
* Clean up API keys with expired grace periods.
|
||||
*
|
||||
* When an API key is rotated, the old key enters a grace period where
|
||||
* both keys are valid. This command revokes keys whose grace period
|
||||
* has ended.
|
||||
*/
|
||||
class CleanupExpiredGracePeriods extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'api:cleanup-grace-periods
|
||||
{--dry-run : Show what would be revoked without actually revoking}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = 'Revoke API keys with expired grace periods after rotation';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(ApiKeyService $service): int
|
||||
{
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('DRY RUN MODE - No keys will be revoked');
|
||||
$this->newLine();
|
||||
|
||||
// Count keys that would be cleaned up
|
||||
$count = \Mod\Api\Models\ApiKey::gracePeriodExpired()
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
|
||||
if ($count === 0) {
|
||||
$this->info('No API keys with expired grace periods found.');
|
||||
} else {
|
||||
$this->info("Would revoke {$count} API key(s) with expired grace periods.");
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info('Cleaning up API keys with expired grace periods...');
|
||||
|
||||
$count = $service->cleanupExpiredGracePeriods();
|
||||
|
||||
if ($count === 0) {
|
||||
$this->info('No API keys with expired grace periods found.');
|
||||
} else {
|
||||
$this->info("Revoked {$count} API key(s) with expired grace periods.");
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Api\Controllers;
|
||||
|
||||
use Core\Front\Controller;
|
||||
use Core\Mod\Tenant\Models\Package;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class ProductApiController extends Controller
|
||||
{
|
||||
/**
|
||||
* List all available products (packages) for Blesta.
|
||||
*/
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$packages = Package::active()
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn (Package $package) => [
|
||||
'code' => $package->code,
|
||||
'name' => $package->name,
|
||||
'description' => $package->description,
|
||||
'is_base_package' => $package->is_base_package,
|
||||
'is_stackable' => $package->is_stackable,
|
||||
'feature_count' => $package->features()->count(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'products' => $packages,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single product by code.
|
||||
*/
|
||||
public function show(string $code): JsonResponse
|
||||
{
|
||||
$package = Package::where('code', $code)
|
||||
->with('features')
|
||||
->first();
|
||||
|
||||
if (! $package) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => "Product '{$code}' not found",
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'product' => [
|
||||
'code' => $package->code,
|
||||
'name' => $package->name,
|
||||
'description' => $package->description,
|
||||
'is_base_package' => $package->is_base_package,
|
||||
'is_stackable' => $package->is_stackable,
|
||||
'is_active' => $package->is_active,
|
||||
'features' => $package->features->map(fn ($feature) => [
|
||||
'code' => $feature->code,
|
||||
'name' => $feature->name,
|
||||
'type' => $feature->type,
|
||||
'limit_value' => $feature->pivot->limit_value,
|
||||
]),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection test endpoint.
|
||||
*/
|
||||
public function ping(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Host Hub API is operational',
|
||||
'version' => '1.0',
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,213 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Api\Controllers;
|
||||
|
||||
use Core\Front\Controller;
|
||||
use Core\Mod\Agentic\Jobs\ProcessContentTask;
|
||||
use Core\Mod\Content\Models\ContentItem;
|
||||
use Core\Mod\Content\Models\ContentTask;
|
||||
use Core\Mod\Agentic\Models\Prompt;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SeoReportController extends Controller
|
||||
{
|
||||
/**
|
||||
* Receive SEO report data from SEO PowerSuite or similar tools.
|
||||
*/
|
||||
public function receive(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'url' => 'required|url',
|
||||
'issues' => 'required|array',
|
||||
'suggestions' => 'nullable|array',
|
||||
'score' => 'nullable|integer|min:0|max:100',
|
||||
'workspace' => 'nullable|string',
|
||||
]);
|
||||
|
||||
// Find content item by URL
|
||||
$item = $this->findContentByUrl($validated['url'], $validated['workspace'] ?? null);
|
||||
|
||||
if (! $item) {
|
||||
Log::info('SEO report received for unknown URL', [
|
||||
'url' => $validated['url'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'ignored',
|
||||
'reason' => 'Content not found for URL',
|
||||
]);
|
||||
}
|
||||
|
||||
// Update SEO metadata
|
||||
$item->updateSeo([
|
||||
'seo_score' => $validated['score'] ?? null,
|
||||
'seo_issues' => $validated['issues'],
|
||||
'seo_suggestions' => $validated['suggestions'] ?? [],
|
||||
]);
|
||||
|
||||
Log::info('SEO report processed', [
|
||||
'content_id' => $item->id,
|
||||
'score' => $validated['score'],
|
||||
'issue_count' => count($validated['issues']),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'received',
|
||||
'content_id' => $item->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SEO issues for a workspace.
|
||||
*/
|
||||
public function issues(Request $request, string $workspaceSlug): JsonResponse
|
||||
{
|
||||
$workspace = Workspace::where('slug', $workspaceSlug)->first();
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['error' => 'Unknown workspace'], 404);
|
||||
}
|
||||
|
||||
$minScore = $request->input('min_score');
|
||||
$maxScore = $request->input('max_score');
|
||||
$hasIssues = $request->boolean('has_issues', true);
|
||||
|
||||
$items = ContentItem::query()
|
||||
->where('workspace_id', $workspace->id)
|
||||
->whereHas('seoMetadata', function ($query) use ($minScore, $maxScore, $hasIssues) {
|
||||
if ($hasIssues) {
|
||||
$query->whereNotNull('seo_issues')
|
||||
->whereJsonLength('seo_issues', '>', 0);
|
||||
}
|
||||
|
||||
if ($minScore !== null) {
|
||||
$query->where('seo_score', '>=', (int) $minScore);
|
||||
}
|
||||
|
||||
if ($maxScore !== null) {
|
||||
$query->where('seo_score', '<=', (int) $maxScore);
|
||||
}
|
||||
})
|
||||
->with('seoMetadata')
|
||||
->orderBy('updated_at', 'desc')
|
||||
->paginate(20);
|
||||
|
||||
return response()->json([
|
||||
'data' => $items->map(fn ($item) => [
|
||||
'id' => $item->id,
|
||||
'title' => $item->title,
|
||||
'slug' => $item->slug,
|
||||
'type' => $item->type,
|
||||
'status' => $item->status,
|
||||
'seo' => [
|
||||
'score' => $item->seoMetadata?->seo_score,
|
||||
'issue_count' => count($item->seoMetadata?->seo_issues ?? []),
|
||||
'issues' => $item->seoMetadata?->seo_issues,
|
||||
'suggestions' => $item->seoMetadata?->seo_suggestions,
|
||||
],
|
||||
'updated_at' => $item->updated_at->toIso8601String(),
|
||||
]),
|
||||
'meta' => [
|
||||
'current_page' => $items->currentPage(),
|
||||
'last_page' => $items->lastPage(),
|
||||
'per_page' => $items->perPage(),
|
||||
'total' => $items->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an AI task to fix SEO issues.
|
||||
*/
|
||||
public function generateTask(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'content_item_id' => 'required|exists:content_items,id',
|
||||
'improvement_type' => 'required|in:title,description,content,schema,all',
|
||||
]);
|
||||
|
||||
$item = ContentItem::with('seoMetadata')->findOrFail($validated['content_item_id']);
|
||||
|
||||
// Find the appropriate SEO prompt
|
||||
$promptName = match ($validated['improvement_type']) {
|
||||
'title' => 'seo-title-optimizer',
|
||||
'description' => 'seo-description-optimizer',
|
||||
'content' => 'seo-content-optimizer',
|
||||
'schema' => 'seo-schema-generator',
|
||||
'all' => 'seo-full-optimization',
|
||||
};
|
||||
|
||||
$prompt = Prompt::where('name', $promptName)->active()->first();
|
||||
|
||||
if (! $prompt) {
|
||||
return response()->json([
|
||||
'error' => "Prompt '{$promptName}' not found or inactive",
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Create the task
|
||||
$task = ContentTask::create([
|
||||
'workspace_id' => $item->workspace_id,
|
||||
'prompt_id' => $prompt->id,
|
||||
'status' => ContentTask::STATUS_PENDING,
|
||||
'priority' => ContentTask::PRIORITY_NORMAL,
|
||||
'input_data' => [
|
||||
'title' => $item->title,
|
||||
'slug' => $item->slug,
|
||||
'excerpt' => $item->excerpt,
|
||||
'content' => $item->content_html_clean,
|
||||
'current_seo_title' => $item->seoMetadata?->title,
|
||||
'current_seo_description' => $item->seoMetadata?->description,
|
||||
'seo_issues' => $item->seoMetadata?->seo_issues ?? [],
|
||||
'seo_suggestions' => $item->seoMetadata?->seo_suggestions ?? [],
|
||||
'focus_keyword' => $item->seoMetadata?->focus_keyword,
|
||||
],
|
||||
'target_type' => ContentItem::class,
|
||||
'target_id' => $item->id,
|
||||
]);
|
||||
|
||||
// Dispatch for processing
|
||||
ProcessContentTask::dispatch($task);
|
||||
|
||||
Log::info('SEO improvement task created', [
|
||||
'task_id' => $task->id,
|
||||
'content_id' => $item->id,
|
||||
'type' => $validated['improvement_type'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'task_id' => $task->id,
|
||||
'status' => 'queued',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find content item by URL.
|
||||
*/
|
||||
protected function findContentByUrl(string $url, ?string $workspaceSlug = null): ?ContentItem
|
||||
{
|
||||
// Extract slug from URL
|
||||
$path = parse_url($url, PHP_URL_PATH);
|
||||
$slug = basename($path);
|
||||
|
||||
// Remove common URL patterns
|
||||
$slug = preg_replace('/\.(html?|php)$/', '', $slug);
|
||||
|
||||
$query = ContentItem::query()->where('slug', $slug);
|
||||
|
||||
if ($workspaceSlug) {
|
||||
$workspace = Workspace::where('slug', $workspaceSlug)->first();
|
||||
if ($workspace) {
|
||||
$query->where('workspace_id', $workspace->id);
|
||||
}
|
||||
}
|
||||
|
||||
return $query->first();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Api\Controllers\Social;
|
||||
|
||||
/**
|
||||
* @deprecated Use Mod\Social\Controllers\Api\AddPostToQueueController instead.
|
||||
* @see \Core\Mod\Social\Controllers\Api\AddPostToQueueController
|
||||
*/
|
||||
class AddPostToQueueController extends \Core\Mod\Social\Controllers\Api\AddPostToQueueController
|
||||
{
|
||||
// Backward compatibility alias
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Api\Controllers\Social;
|
||||
|
||||
/**
|
||||
* @deprecated Use Mod\Social\Controllers\Api\ApprovePostController instead.
|
||||
* @see \Core\Mod\Social\Controllers\Api\ApprovePostController
|
||||
*/
|
||||
class ApprovePostController extends \Core\Mod\Social\Controllers\Api\ApprovePostController
|
||||
{
|
||||
// Backward compatibility alias
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Api\Controllers\Social;
|
||||
|
||||
/**
|
||||
* @deprecated Use Mod\Social\Controllers\Api\BulkDeletePostsController instead.
|
||||
* @see \Core\Mod\Social\Controllers\Api\BulkDeletePostsController
|
||||
*/
|
||||
class BulkDeletePostsController extends \Core\Mod\Social\Controllers\Api\BulkDeletePostsController
|
||||
{
|
||||
// Backward compatibility alias
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Api\Controllers\Social;
|
||||
|
||||
/**
|
||||
* @deprecated Use Mod\Social\Controllers\Api\MediaController instead.
|
||||
* @see \Core\Mod\Social\Controllers\Api\MediaController
|
||||
*/
|
||||
class MediaController extends \Core\Mod\Social\Controllers\Api\MediaController
|
||||
{
|
||||
// Backward compatibility alias
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Api\Controllers\Social;
|
||||
|
||||
/**
|
||||
* @deprecated Use Mod\Social\Controllers\Api\SchedulePostController instead.
|
||||
* @see \Core\Mod\Social\Controllers\Api\SchedulePostController
|
||||
*/
|
||||
class SchedulePostController extends \Core\Mod\Social\Controllers\Api\SchedulePostController
|
||||
{
|
||||
// Backward compatibility alias
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Api\Controllers\Social;
|
||||
|
||||
/**
|
||||
* @deprecated Use Mod\Social\Controllers\Api\SocialAccountController instead.
|
||||
* @see \Core\Mod\Social\Controllers\Api\SocialAccountController
|
||||
*/
|
||||
class SocialAccountController extends \Core\Mod\Social\Controllers\Api\SocialAccountController
|
||||
{
|
||||
// Backward compatibility alias
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Api\Controllers\Social;
|
||||
|
||||
/**
|
||||
* @deprecated Use Mod\Social\Controllers\Api\SocialAnalyticsController instead.
|
||||
* @see \Core\Mod\Social\Controllers\Api\SocialAnalyticsController
|
||||
*/
|
||||
class SocialAnalyticsController extends \Core\Mod\Social\Controllers\Api\SocialAnalyticsController
|
||||
{
|
||||
// Backward compatibility alias
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Api\Controllers\Social;
|
||||
|
||||
/**
|
||||
* @deprecated Use Mod\Social\Controllers\Api\SocialPostController instead.
|
||||
* @see \Core\Mod\Social\Controllers\Api\SocialPostController
|
||||
*/
|
||||
class SocialPostController extends \Core\Mod\Social\Controllers\Api\SocialPostController
|
||||
{
|
||||
// Backward compatibility alias
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Api\Controllers\Social;
|
||||
|
||||
/**
|
||||
* @deprecated Use Mod\Social\Controllers\Api\TagController instead.
|
||||
* @see \Core\Mod\Social\Controllers\Api\TagController
|
||||
*/
|
||||
class TagController extends \Core\Mod\Social\Controllers\Api\TagController
|
||||
{
|
||||
// Backward compatibility alias
|
||||
}
|
||||
|
|
@ -1,231 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Api\Controllers;
|
||||
|
||||
use Core\Front\Controller;
|
||||
use Core\Mod\Analytics\Models\Website;
|
||||
use Core\Mod\Notify\Models\PushWebsite;
|
||||
use Core\Mod\Trust\Models\Campaign;
|
||||
use Core\Mod\Analytics\Services\AnalyticsTrackingService;
|
||||
use Core\Mod\Trust\Services\TrustService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Unified Pixel Controller.
|
||||
*
|
||||
* Provides configuration and tracking endpoints for the unified
|
||||
* hosthub-pixel.js tracking script.
|
||||
*/
|
||||
class UnifiedPixelController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected AnalyticsTrackingService $analyticsTracking,
|
||||
protected TrustService $trustService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get unified pixel configuration.
|
||||
*
|
||||
* Returns which features are enabled for a given pixel key and their settings.
|
||||
* The pixel key can be associated with any combination of:
|
||||
* - Analytics website
|
||||
* - Push notification website
|
||||
* - Social proof campaign
|
||||
*/
|
||||
public function config(Request $request): JsonResponse
|
||||
{
|
||||
$pixelKey = $request->query('pixel_key');
|
||||
|
||||
if (! $pixelKey) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'error' => 'Missing pixel_key parameter',
|
||||
], 400);
|
||||
}
|
||||
|
||||
// Cache config for 5 minutes to reduce database lookups
|
||||
$cacheKey = "pixel_config:{$pixelKey}";
|
||||
$config = Cache::remember($cacheKey, now()->addMinutes(5), function () use ($pixelKey) {
|
||||
return $this->buildConfig($pixelKey);
|
||||
});
|
||||
|
||||
if (! $config) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'error' => 'Invalid or disabled pixel key',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'config' => $config,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build configuration for a pixel key.
|
||||
*/
|
||||
protected function buildConfig(string $pixelKey): ?array
|
||||
{
|
||||
$config = [
|
||||
'analytics' => false,
|
||||
'push' => false,
|
||||
'socialproof' => false,
|
||||
];
|
||||
|
||||
$foundAny = false;
|
||||
|
||||
// Check analytics
|
||||
$analyticsWebsite = Website::where('pixel_key', $pixelKey)
|
||||
->active()
|
||||
->first();
|
||||
|
||||
if ($analyticsWebsite) {
|
||||
$foundAny = true;
|
||||
$config['analytics'] = true;
|
||||
$config['analytics_settings'] = [
|
||||
'tracking_type' => $analyticsWebsite->tracking_type ?? 'lightweight',
|
||||
'track_clicks' => $analyticsWebsite->settings['track_clicks'] ?? false,
|
||||
'track_scroll' => $analyticsWebsite->settings['track_scroll'] ?? false,
|
||||
'track_outbound' => $analyticsWebsite->settings['track_outbound'] ?? false,
|
||||
'session_timeout' => $analyticsWebsite->settings['session_timeout'] ?? 30,
|
||||
];
|
||||
}
|
||||
|
||||
// Check push notifications
|
||||
$pushWebsite = PushWebsite::where('pixel_key', $pixelKey)
|
||||
->where('is_enabled', true)
|
||||
->first();
|
||||
|
||||
if ($pushWebsite) {
|
||||
$foundAny = true;
|
||||
$config['push'] = true;
|
||||
$widgetSettings = $pushWebsite->widget_settings ?? [];
|
||||
$config['push_settings'] = [
|
||||
'auto_prompt' => $widgetSettings['auto_prompt'] ?? true,
|
||||
'prompt_delay' => $widgetSettings['prompt_delay'] ?? 3,
|
||||
'widget_position' => $widgetSettings['widget_position'] ?? 'top-right',
|
||||
];
|
||||
}
|
||||
|
||||
// Check social proof
|
||||
$socialProofCampaign = Campaign::where('pixel_key', $pixelKey)
|
||||
->enabled()
|
||||
->first();
|
||||
|
||||
if ($socialProofCampaign) {
|
||||
$foundAny = true;
|
||||
$config['socialproof'] = true;
|
||||
$config['socialproof_settings'] = [
|
||||
'primary_color' => $socialProofCampaign->primary_color,
|
||||
'logo' => $socialProofCampaign->logo,
|
||||
];
|
||||
}
|
||||
|
||||
// If no services found for this pixel key, return null
|
||||
if (! $foundAny) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified tracking endpoint.
|
||||
*
|
||||
* Accepts tracking data and routes it to the appropriate service
|
||||
* based on the event type.
|
||||
*/
|
||||
public function track(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'pixel_key' => 'required|string|max:64',
|
||||
'type' => 'required|string|in:pageview,event,goal,session_end',
|
||||
'visitor_id' => 'sometimes|string|max:64',
|
||||
'session_id' => 'sometimes|string|max:64',
|
||||
'timestamp' => 'sometimes|date',
|
||||
'path' => 'sometimes|string|max:512',
|
||||
'title' => 'sometimes|string|max:256',
|
||||
'url' => 'sometimes|string|max:512',
|
||||
'referrer' => 'sometimes|nullable|string|max:512',
|
||||
'referrer_host' => 'sometimes|nullable|string|max:256',
|
||||
'device_type' => 'sometimes|string|max:16',
|
||||
'browser' => 'sometimes|string|max:32',
|
||||
'os' => 'sometimes|string|max:32',
|
||||
'screen' => 'sometimes|array',
|
||||
'utm_source' => 'sometimes|nullable|string|max:128',
|
||||
'utm_medium' => 'sometimes|nullable|string|max:128',
|
||||
'utm_campaign' => 'sometimes|nullable|string|max:128',
|
||||
'utm_term' => 'sometimes|nullable|string|max:128',
|
||||
'utm_content' => 'sometimes|nullable|string|max:128',
|
||||
'event_name' => 'sometimes|string|max:128',
|
||||
'event_data' => 'sometimes|array',
|
||||
'goal_key' => 'sometimes|string|max:64',
|
||||
'value' => 'sometimes|numeric',
|
||||
'duration' => 'sometimes|integer|min:0',
|
||||
]);
|
||||
|
||||
// Find the analytics website for this pixel key
|
||||
$website = Website::where('pixel_key', $validated['pixel_key'])
|
||||
->active()
|
||||
->first();
|
||||
|
||||
if (! $website) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'error' => 'Invalid or disabled pixel key',
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Map the unified format to the analytics tracking format
|
||||
$trackingData = [
|
||||
'type' => $validated['type'] === 'event' ? 'custom' : $validated['type'],
|
||||
'visitor_id' => $validated['visitor_id'] ?? null,
|
||||
'session_id' => $validated['session_id'] ?? null,
|
||||
'path' => $validated['path'] ?? '/',
|
||||
'title' => $validated['title'] ?? null,
|
||||
'referrer' => $validated['referrer'] ?? null,
|
||||
'utm_source' => $validated['utm_source'] ?? null,
|
||||
'utm_medium' => $validated['utm_medium'] ?? null,
|
||||
'utm_campaign' => $validated['utm_campaign'] ?? null,
|
||||
'utm_term' => $validated['utm_term'] ?? null,
|
||||
'utm_content' => $validated['utm_content'] ?? null,
|
||||
'screen_width' => $validated['screen']['width'] ?? null,
|
||||
'screen_height' => $validated['screen']['height'] ?? null,
|
||||
];
|
||||
|
||||
// Add event-specific data
|
||||
if ($validated['type'] === 'event') {
|
||||
$trackingData['event_name'] = $validated['event_name'] ?? 'custom';
|
||||
$trackingData['properties'] = $validated['event_data'] ?? [];
|
||||
}
|
||||
|
||||
// Add goal-specific data
|
||||
if ($validated['type'] === 'goal') {
|
||||
$trackingData['event_name'] = $validated['goal_key'] ?? 'goal';
|
||||
$trackingData['properties'] = ['value' => $validated['value'] ?? null];
|
||||
}
|
||||
|
||||
// Track the event
|
||||
$event = $this->analyticsTracking->track($website, $trackingData, $request);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'event_id' => $event?->id,
|
||||
'visitor_id' => $event?->visitor?->visitor_uuid,
|
||||
'session_id' => $event?->session?->session_uuid,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached configuration for a pixel key.
|
||||
*
|
||||
* Called when website/campaign settings are updated.
|
||||
*/
|
||||
public static function clearConfigCache(string $pixelKey): void
|
||||
{
|
||||
Cache::forget("pixel_config:{$pixelKey}");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Api\Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
use Mod\Api\Models\ApiKey;
|
||||
use Mod\Tenant\Models\User;
|
||||
use Mod\Tenant\Models\Workspace;
|
||||
|
||||
/**
|
||||
* Factory for generating ApiKey test instances.
|
||||
*
|
||||
* @extends Factory<ApiKey>
|
||||
*/
|
||||
class ApiKeyFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The name of the factory's corresponding model.
|
||||
*
|
||||
* @var class-string<ApiKey>
|
||||
*/
|
||||
protected $model = ApiKey::class;
|
||||
|
||||
/**
|
||||
* Store the plain key for testing.
|
||||
*/
|
||||
private ?string $plainKey = null;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$plainKey = Str::random(48);
|
||||
$prefix = 'hk_'.Str::random(8);
|
||||
$this->plainKey = "{$prefix}_{$plainKey}";
|
||||
|
||||
return [
|
||||
'workspace_id' => Workspace::factory(),
|
||||
'user_id' => User::factory(),
|
||||
'name' => fake()->words(2, true).' API Key',
|
||||
'key' => hash('sha256', $plainKey),
|
||||
'prefix' => $prefix,
|
||||
'scopes' => [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE],
|
||||
'server_scopes' => null,
|
||||
'last_used_at' => null,
|
||||
'expires_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the plain key after creation.
|
||||
* Must be called immediately after create() to get the plain key.
|
||||
*/
|
||||
public function getPlainKey(): ?string
|
||||
{
|
||||
return $this->plainKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a key with specific known credentials for testing.
|
||||
*
|
||||
* @return array{api_key: ApiKey, plain_key: string}
|
||||
*/
|
||||
public static function createWithPlainKey(
|
||||
?Workspace $workspace = null,
|
||||
?User $user = null,
|
||||
array $scopes = [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE],
|
||||
?\DateTimeInterface $expiresAt = null
|
||||
): array {
|
||||
$workspace ??= Workspace::factory()->create();
|
||||
$user ??= User::factory()->create();
|
||||
|
||||
return ApiKey::generate(
|
||||
$workspace->id,
|
||||
$user->id,
|
||||
fake()->words(2, true).' API Key',
|
||||
$scopes,
|
||||
$expiresAt
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the key 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 key 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 key has expired.
|
||||
*/
|
||||
public function expired(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'expires_at' => now()->subDays(1),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set specific scopes.
|
||||
*
|
||||
* @param array<string> $scopes
|
||||
*/
|
||||
public function withScopes(array $scopes): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'scopes' => $scopes,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set read-only scope.
|
||||
*/
|
||||
public function readOnly(): static
|
||||
{
|
||||
return $this->withScopes([ApiKey::SCOPE_READ]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set all scopes (read, write, delete).
|
||||
*/
|
||||
public function fullAccess(): static
|
||||
{
|
||||
return $this->withScopes(ApiKey::ALL_SCOPES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set specific server scopes.
|
||||
*
|
||||
* @param array<string>|null $servers
|
||||
*/
|
||||
public function withServerScopes(?array $servers): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'server_scopes' => $servers,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a revoked (soft-deleted) key.
|
||||
*/
|
||||
public function revoked(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'deleted_at' => now()->subDay(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
182
packages/core-api/src/Mod/Api/Jobs/DeliverWebhookJob.php
Normal file
182
packages/core-api/src/Mod/Api/Jobs/DeliverWebhookJob.php
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Api\Jobs;
|
||||
|
||||
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 Mod\Api\Models\WebhookDelivery;
|
||||
|
||||
/**
|
||||
* Delivers webhook payloads to registered endpoints.
|
||||
*
|
||||
* Implements exponential backoff retry logic:
|
||||
* - Attempt 1: Immediate
|
||||
* - Attempt 2: 1 minute delay
|
||||
* - Attempt 3: 5 minutes delay
|
||||
* - Attempt 4: 30 minutes delay
|
||||
* - Attempt 5: 2 hours delay
|
||||
* - Attempt 6 (final): 24 hours delay
|
||||
*/
|
||||
class DeliverWebhookJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
/**
|
||||
* Delete the job if its models no longer exist.
|
||||
*/
|
||||
public bool $deleteWhenMissingModels = true;
|
||||
|
||||
/**
|
||||
* The number of times the job may be attempted.
|
||||
* We handle retries manually with exponential backoff.
|
||||
*/
|
||||
public int $tries = 1;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public WebhookDelivery $delivery
|
||||
) {
|
||||
// Use dedicated webhook queue if configured
|
||||
$this->queue = config('api.webhooks.queue', 'default');
|
||||
|
||||
$connection = config('api.webhooks.queue_connection');
|
||||
if ($connection) {
|
||||
$this->connection = $connection;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
// Don't deliver if endpoint is disabled
|
||||
$endpoint = $this->delivery->endpoint;
|
||||
if (! $endpoint || ! $endpoint->shouldReceive($this->delivery->event_type)) {
|
||||
Log::info('Webhook delivery skipped - endpoint inactive or does not receive this event', [
|
||||
'delivery_id' => $this->delivery->id,
|
||||
'event_type' => $this->delivery->event_type,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Get delivery payload with signature headers
|
||||
$deliveryPayload = $this->delivery->getDeliveryPayload();
|
||||
$timeout = config('api.webhooks.timeout', 30);
|
||||
|
||||
Log::info('Attempting webhook delivery', [
|
||||
'delivery_id' => $this->delivery->id,
|
||||
'endpoint_url' => $endpoint->url,
|
||||
'event_type' => $this->delivery->event_type,
|
||||
'attempt' => $this->delivery->attempt,
|
||||
]);
|
||||
|
||||
try {
|
||||
$response = Http::timeout($timeout)
|
||||
->withHeaders($deliveryPayload['headers'])
|
||||
->withBody($deliveryPayload['body'], 'application/json')
|
||||
->post($endpoint->url);
|
||||
|
||||
$statusCode = $response->status();
|
||||
$responseBody = $response->body();
|
||||
|
||||
// Success is any 2xx status code
|
||||
if ($response->successful()) {
|
||||
$this->delivery->markSuccess($statusCode, $responseBody);
|
||||
|
||||
Log::info('Webhook delivered successfully', [
|
||||
'delivery_id' => $this->delivery->id,
|
||||
'status_code' => $statusCode,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-2xx response - mark as failed and potentially retry
|
||||
$this->handleFailure($statusCode, $responseBody);
|
||||
|
||||
} catch (\Illuminate\Http\Client\ConnectionException $e) {
|
||||
// Connection timeout or refused
|
||||
$this->handleFailure(0, 'Connection failed: '.$e->getMessage());
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
// Unexpected error
|
||||
$this->handleFailure(0, 'Unexpected error: '.$e->getMessage());
|
||||
|
||||
Log::error('Webhook delivery unexpected error', [
|
||||
'delivery_id' => $this->delivery->id,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a failed delivery attempt.
|
||||
*/
|
||||
protected function handleFailure(int $statusCode, ?string $responseBody): void
|
||||
{
|
||||
Log::warning('Webhook delivery failed', [
|
||||
'delivery_id' => $this->delivery->id,
|
||||
'attempt' => $this->delivery->attempt,
|
||||
'status_code' => $statusCode,
|
||||
'can_retry' => $this->delivery->canRetry(),
|
||||
]);
|
||||
|
||||
// Mark as failed (this also schedules retry if attempts remain)
|
||||
$this->delivery->markFailed($statusCode, $responseBody);
|
||||
|
||||
// If we can retry, dispatch a new job with the appropriate delay
|
||||
if ($this->delivery->canRetry() && $this->delivery->next_retry_at) {
|
||||
$delay = $this->delivery->next_retry_at->diffInSeconds(now());
|
||||
|
||||
Log::info('Scheduling webhook retry', [
|
||||
'delivery_id' => $this->delivery->id,
|
||||
'next_attempt' => $this->delivery->attempt,
|
||||
'delay_seconds' => $delay,
|
||||
'next_retry_at' => $this->delivery->next_retry_at->toIso8601String(),
|
||||
]);
|
||||
|
||||
// Dispatch retry with calculated delay
|
||||
self::dispatch($this->delivery->fresh())->delay($delay);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a job failure.
|
||||
*/
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
Log::error('Webhook delivery job failed completely', [
|
||||
'delivery_id' => $this->delivery->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tags for the job.
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
public function tags(): array
|
||||
{
|
||||
return [
|
||||
'webhook',
|
||||
'webhook:'.$this->delivery->webhook_endpoint_id,
|
||||
'event:'.$this->delivery->event_type,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Api\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Authenticate Commerce Provisioning API requests using Bearer token.
|
||||
*
|
||||
* The token is compared against the configured Commerce API secret.
|
||||
* Used for internal service provisioning and entitlement management endpoints.
|
||||
*/
|
||||
class CommerceApiAuth
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$token = $request->bearerToken();
|
||||
|
||||
if (! $token) {
|
||||
return $this->unauthorized('API token required. Use Authorization: Bearer <token>');
|
||||
}
|
||||
|
||||
$expectedToken = config('services.commerce.api_secret');
|
||||
|
||||
if (! $expectedToken) {
|
||||
return response()->json([
|
||||
'error' => 'configuration_error',
|
||||
'message' => 'Commerce API not configured',
|
||||
], 500);
|
||||
}
|
||||
|
||||
if (! hash_equals($expectedToken, $token)) {
|
||||
return $this->unauthorized('Invalid API token');
|
||||
}
|
||||
|
||||
$request->attributes->set('auth_type', 'commerce_api');
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return 401 Unauthorized response.
|
||||
*/
|
||||
protected function unauthorized(string $message): Response
|
||||
{
|
||||
return response()->json([
|
||||
'error' => 'unauthorized',
|
||||
'message' => $message,
|
||||
], 401);
|
||||
}
|
||||
}
|
||||
64
packages/core-api/src/Mod/Api/Middleware/PublicApiCors.php
Normal file
64
packages/core-api/src/Mod/Api/Middleware/PublicApiCors.php
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Api\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* CORS middleware for public API endpoints.
|
||||
*
|
||||
* Public endpoints like the unified pixel need to be accessible from any
|
||||
* customer website, so we allow all origins. These endpoints are rate-limited
|
||||
* and do not expose sensitive data.
|
||||
*/
|
||||
class PublicApiCors
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// Handle preflight OPTIONS request
|
||||
if ($request->isMethod('OPTIONS')) {
|
||||
return $this->buildPreflightResponse($request);
|
||||
}
|
||||
|
||||
$response = $next($request);
|
||||
|
||||
return $this->addCorsHeaders($response, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build preflight response for OPTIONS requests.
|
||||
*/
|
||||
protected function buildPreflightResponse(Request $request): Response
|
||||
{
|
||||
$response = response('', 204);
|
||||
|
||||
return $this->addCorsHeaders($response, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add CORS headers to response.
|
||||
*/
|
||||
protected function addCorsHeaders(Response $response, Request $request): Response
|
||||
{
|
||||
$origin = $request->header('Origin', '*');
|
||||
|
||||
// Allow any origin for public widget/pixel endpoints
|
||||
$response->headers->set('Access-Control-Allow-Origin', $origin);
|
||||
$response->headers->set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
$response->headers->set('Access-Control-Allow-Headers', 'Content-Type, Accept, X-Requested-With');
|
||||
$response->headers->set('Access-Control-Expose-Headers', 'X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After');
|
||||
$response->headers->set('Access-Control-Max-Age', '3600');
|
||||
|
||||
// Vary on Origin for proper caching
|
||||
$response->headers->set('Vary', 'Origin');
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
81
packages/core-api/src/Mod/Api/Middleware/TrackApiUsage.php
Normal file
81
packages/core-api/src/Mod/Api/Middleware/TrackApiUsage.php
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Api\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Mod\Api\Models\ApiKey;
|
||||
use Mod\Api\Services\ApiUsageService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Track API Usage Middleware.
|
||||
*
|
||||
* Records request/response metrics for API analytics.
|
||||
* Should be applied after authentication middleware.
|
||||
*/
|
||||
class TrackApiUsage
|
||||
{
|
||||
public function __construct(
|
||||
protected ApiUsageService $usageService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// Record start time
|
||||
$startTime = microtime(true);
|
||||
|
||||
// Process the request
|
||||
$response = $next($request);
|
||||
|
||||
// Calculate response time
|
||||
$responseTimeMs = (int) ((microtime(true) - $startTime) * 1000);
|
||||
|
||||
// Only track if we have an authenticated API key
|
||||
$apiKey = $request->attributes->get('api_key');
|
||||
|
||||
if ($apiKey instanceof ApiKey) {
|
||||
$this->recordUsage($request, $response, $apiKey, $responseTimeMs);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the API usage.
|
||||
*/
|
||||
protected function recordUsage(
|
||||
Request $request,
|
||||
Response $response,
|
||||
ApiKey $apiKey,
|
||||
int $responseTimeMs
|
||||
): void {
|
||||
try {
|
||||
$this->usageService->record(
|
||||
apiKeyId: $apiKey->id,
|
||||
workspaceId: $apiKey->workspace_id,
|
||||
endpoint: $request->path(),
|
||||
method: $request->method(),
|
||||
statusCode: $response->getStatusCode(),
|
||||
responseTimeMs: $responseTimeMs,
|
||||
requestSize: strlen($request->getContent()),
|
||||
responseSize: strlen($response->getContent()),
|
||||
ipAddress: $request->ip(),
|
||||
userAgent: $request->userAgent()
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
// Don't let analytics failures affect the API response
|
||||
Log::warning('Failed to record API usage', [
|
||||
'error' => $e->getMessage(),
|
||||
'api_key_id' => $apiKey->id,
|
||||
'endpoint' => $request->path(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
135
packages/core-api/src/Mod/Api/Models/ApiUsage.php
Normal file
135
packages/core-api/src/Mod/Api/Models/ApiUsage.php
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Api\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Mod\Tenant\Models\Workspace;
|
||||
|
||||
/**
|
||||
* API Usage - individual API request log entry.
|
||||
*
|
||||
* Tracks each API call with timing, status, and size metrics.
|
||||
*/
|
||||
class ApiUsage extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'api_usage';
|
||||
|
||||
protected $fillable = [
|
||||
'api_key_id',
|
||||
'workspace_id',
|
||||
'endpoint',
|
||||
'method',
|
||||
'status_code',
|
||||
'response_time_ms',
|
||||
'request_size',
|
||||
'response_size',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Create a usage entry from request/response data.
|
||||
*/
|
||||
public static function record(
|
||||
int $apiKeyId,
|
||||
int $workspaceId,
|
||||
string $endpoint,
|
||||
string $method,
|
||||
int $statusCode,
|
||||
int $responseTimeMs,
|
||||
?int $requestSize = null,
|
||||
?int $responseSize = null,
|
||||
?string $ipAddress = null,
|
||||
?string $userAgent = null
|
||||
): static {
|
||||
return static::create([
|
||||
'api_key_id' => $apiKeyId,
|
||||
'workspace_id' => $workspaceId,
|
||||
'endpoint' => $endpoint,
|
||||
'method' => strtoupper($method),
|
||||
'status_code' => $statusCode,
|
||||
'response_time_ms' => $responseTimeMs,
|
||||
'request_size' => $requestSize,
|
||||
'response_size' => $responseSize,
|
||||
'ip_address' => $ipAddress,
|
||||
'user_agent' => $userAgent ? substr($userAgent, 0, 500) : null,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this was a successful request (2xx status).
|
||||
*/
|
||||
public function isSuccess(): bool
|
||||
{
|
||||
return $this->status_code >= 200 && $this->status_code < 300;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this was a client error (4xx status).
|
||||
*/
|
||||
public function isClientError(): bool
|
||||
{
|
||||
return $this->status_code >= 400 && $this->status_code < 500;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this was a server error (5xx status).
|
||||
*/
|
||||
public function isServerError(): bool
|
||||
{
|
||||
return $this->status_code >= 500;
|
||||
}
|
||||
|
||||
// Relationships
|
||||
public function apiKey(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ApiKey::class);
|
||||
}
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
// Scopes
|
||||
public function scopeForKey($query, int $apiKeyId)
|
||||
{
|
||||
return $query->where('api_key_id', $apiKeyId);
|
||||
}
|
||||
|
||||
public function scopeForWorkspace($query, int $workspaceId)
|
||||
{
|
||||
return $query->where('workspace_id', $workspaceId);
|
||||
}
|
||||
|
||||
public function scopeForEndpoint($query, string $endpoint)
|
||||
{
|
||||
return $query->where('endpoint', $endpoint);
|
||||
}
|
||||
|
||||
public function scopeSuccessful($query)
|
||||
{
|
||||
return $query->whereBetween('status_code', [200, 299]);
|
||||
}
|
||||
|
||||
public function scopeErrors($query)
|
||||
{
|
||||
return $query->where('status_code', '>=', 400);
|
||||
}
|
||||
|
||||
public function scopeBetween($query, $startDate, $endDate)
|
||||
{
|
||||
return $query->whereBetween('created_at', [$startDate, $endDate]);
|
||||
}
|
||||
}
|
||||
172
packages/core-api/src/Mod/Api/Models/ApiUsageDaily.php
Normal file
172
packages/core-api/src/Mod/Api/Models/ApiUsageDaily.php
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Api\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Mod\Tenant\Models\Workspace;
|
||||
|
||||
/**
|
||||
* API Usage Daily - aggregated daily API statistics.
|
||||
*
|
||||
* Pre-computed daily stats for efficient reporting and dashboards.
|
||||
*/
|
||||
class ApiUsageDaily extends Model
|
||||
{
|
||||
protected $table = 'api_usage_daily';
|
||||
|
||||
protected $fillable = [
|
||||
'api_key_id',
|
||||
'workspace_id',
|
||||
'date',
|
||||
'endpoint',
|
||||
'method',
|
||||
'request_count',
|
||||
'success_count',
|
||||
'error_count',
|
||||
'total_response_time_ms',
|
||||
'min_response_time_ms',
|
||||
'max_response_time_ms',
|
||||
'total_request_size',
|
||||
'total_response_size',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'date' => 'date',
|
||||
];
|
||||
|
||||
/**
|
||||
* Update or create daily stats from a usage record.
|
||||
*
|
||||
* Uses Laravel's upsert() for database portability while maintaining
|
||||
* atomic operations. For increment operations, we use a two-step approach:
|
||||
* first upsert the base record, then atomically update counters.
|
||||
*/
|
||||
public static function recordFromUsage(ApiUsage $usage): static
|
||||
{
|
||||
$isSuccess = $usage->isSuccess();
|
||||
$isError = $usage->status_code >= 400;
|
||||
$date = $usage->created_at->toDateString();
|
||||
$now = now();
|
||||
|
||||
// Unique key for this daily aggregation
|
||||
$uniqueKey = [
|
||||
'api_key_id' => $usage->api_key_id,
|
||||
'workspace_id' => $usage->workspace_id,
|
||||
'date' => $date,
|
||||
'endpoint' => $usage->endpoint,
|
||||
'method' => $usage->method,
|
||||
];
|
||||
|
||||
// First, ensure the record exists with upsert (database-portable)
|
||||
static::upsert(
|
||||
[
|
||||
...$uniqueKey,
|
||||
'request_count' => 0,
|
||||
'success_count' => 0,
|
||||
'error_count' => 0,
|
||||
'total_response_time_ms' => 0,
|
||||
'total_request_size' => 0,
|
||||
'total_response_size' => 0,
|
||||
'min_response_time_ms' => null,
|
||||
'max_response_time_ms' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
['api_key_id', 'workspace_id', 'date', 'endpoint', 'method'],
|
||||
['updated_at'] // Only touch updated_at if record exists
|
||||
);
|
||||
|
||||
// Then atomically increment counters using query builder
|
||||
$query = static::where($uniqueKey);
|
||||
|
||||
// Build raw update for atomic increments
|
||||
$query->update([
|
||||
'request_count' => DB::raw('request_count + 1'),
|
||||
'success_count' => DB::raw('success_count + '.($isSuccess ? 1 : 0)),
|
||||
'error_count' => DB::raw('error_count + '.($isError ? 1 : 0)),
|
||||
'total_response_time_ms' => DB::raw('total_response_time_ms + '.(int) $usage->response_time_ms),
|
||||
'total_request_size' => DB::raw('total_request_size + '.(int) ($usage->request_size ?? 0)),
|
||||
'total_response_size' => DB::raw('total_response_size + '.(int) ($usage->response_size ?? 0)),
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
// Update min/max response times (these need conditional logic)
|
||||
$responseTimeMs = (int) $usage->response_time_ms;
|
||||
static::where($uniqueKey)
|
||||
->where(function ($q) use ($responseTimeMs) {
|
||||
$q->whereNull('min_response_time_ms')
|
||||
->orWhere('min_response_time_ms', '>', $responseTimeMs);
|
||||
})
|
||||
->update(['min_response_time_ms' => $responseTimeMs]);
|
||||
|
||||
static::where($uniqueKey)
|
||||
->where(function ($q) use ($responseTimeMs) {
|
||||
$q->whereNull('max_response_time_ms')
|
||||
->orWhere('max_response_time_ms', '<', $responseTimeMs);
|
||||
})
|
||||
->update(['max_response_time_ms' => $responseTimeMs]);
|
||||
|
||||
// Retrieve the record for return
|
||||
return static::where($uniqueKey)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average response time.
|
||||
*/
|
||||
public function getAverageResponseTimeMsAttribute(): float
|
||||
{
|
||||
if ($this->request_count === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return round($this->total_response_time_ms / $this->request_count, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate success rate percentage.
|
||||
*/
|
||||
public function getSuccessRateAttribute(): float
|
||||
{
|
||||
if ($this->request_count === 0) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
return round(($this->success_count / $this->request_count) * 100, 2);
|
||||
}
|
||||
|
||||
// Relationships
|
||||
public function apiKey(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ApiKey::class);
|
||||
}
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
// Scopes
|
||||
public function scopeForKey($query, int $apiKeyId)
|
||||
{
|
||||
return $query->where('api_key_id', $apiKeyId);
|
||||
}
|
||||
|
||||
public function scopeForWorkspace($query, int $workspaceId)
|
||||
{
|
||||
return $query->where('workspace_id', $workspaceId);
|
||||
}
|
||||
|
||||
public function scopeForEndpoint($query, string $endpoint)
|
||||
{
|
||||
return $query->where('endpoint', $endpoint);
|
||||
}
|
||||
|
||||
public function scopeBetween($query, $startDate, $endDate)
|
||||
{
|
||||
return $query->whereBetween('date', [$startDate, $endDate]);
|
||||
}
|
||||
}
|
||||
217
packages/core-api/src/Mod/Api/Services/ApiKeyService.php
Normal file
217
packages/core-api/src/Mod/Api/Services/ApiKeyService.php
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Api\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Mod\Api\Models\ApiKey;
|
||||
|
||||
/**
|
||||
* API Key Service - manages API key lifecycle.
|
||||
*
|
||||
* Provides methods for creating, rotating, and managing API keys
|
||||
* with proper validation and logging.
|
||||
*/
|
||||
class ApiKeyService
|
||||
{
|
||||
/**
|
||||
* Create a new API key for a workspace.
|
||||
*
|
||||
* @return array{api_key: ApiKey, plain_key: string}
|
||||
*/
|
||||
public function create(
|
||||
int $workspaceId,
|
||||
int $userId,
|
||||
string $name,
|
||||
array $scopes = [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE],
|
||||
?\DateTimeInterface $expiresAt = null,
|
||||
?array $serverScopes = null
|
||||
): array {
|
||||
// Check workspace key limit
|
||||
$maxKeys = config('api.keys.max_per_workspace', 10);
|
||||
$currentCount = ApiKey::forWorkspace($workspaceId)->active()->count();
|
||||
|
||||
if ($currentCount >= $maxKeys) {
|
||||
throw new \RuntimeException(
|
||||
"Workspace has reached the maximum number of API keys ({$maxKeys})"
|
||||
);
|
||||
}
|
||||
|
||||
$result = ApiKey::generate($workspaceId, $userId, $name, $scopes, $expiresAt);
|
||||
|
||||
// Set server scopes if provided
|
||||
if ($serverScopes !== null) {
|
||||
$result['api_key']->update(['server_scopes' => $serverScopes]);
|
||||
}
|
||||
|
||||
Log::info('API key created', [
|
||||
'key_id' => $result['api_key']->id,
|
||||
'workspace_id' => $workspaceId,
|
||||
'user_id' => $userId,
|
||||
'name' => $name,
|
||||
]);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate an existing API key.
|
||||
*
|
||||
* Creates a new key with the same settings, keeping the old key
|
||||
* valid for a grace period to allow migration.
|
||||
*
|
||||
* @param int $gracePeriodHours Hours the old key remains valid (default: 24)
|
||||
* @return array{api_key: ApiKey, plain_key: string, old_key: ApiKey}
|
||||
*/
|
||||
public function rotate(ApiKey $apiKey, int $gracePeriodHours = ApiKey::DEFAULT_GRACE_PERIOD_HOURS): array
|
||||
{
|
||||
// Don't rotate keys that are already being rotated out
|
||||
if ($apiKey->isInGracePeriod()) {
|
||||
throw new \RuntimeException(
|
||||
'This key is already being rotated. Wait for the grace period to end or end it manually.'
|
||||
);
|
||||
}
|
||||
|
||||
// Don't rotate revoked keys
|
||||
if ($apiKey->trashed()) {
|
||||
throw new \RuntimeException('Cannot rotate a revoked key.');
|
||||
}
|
||||
|
||||
$result = $apiKey->rotate($gracePeriodHours);
|
||||
|
||||
Log::info('API key rotated', [
|
||||
'old_key_id' => $apiKey->id,
|
||||
'new_key_id' => $result['api_key']->id,
|
||||
'workspace_id' => $apiKey->workspace_id,
|
||||
'grace_period_hours' => $gracePeriodHours,
|
||||
'grace_period_ends_at' => $apiKey->fresh()->grace_period_ends_at?->toIso8601String(),
|
||||
]);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke an API key immediately.
|
||||
*/
|
||||
public function revoke(ApiKey $apiKey): void
|
||||
{
|
||||
$apiKey->revoke();
|
||||
|
||||
Log::info('API key revoked', [
|
||||
'key_id' => $apiKey->id,
|
||||
'workspace_id' => $apiKey->workspace_id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* End the grace period for a rotating key and revoke it.
|
||||
*/
|
||||
public function endGracePeriod(ApiKey $apiKey): void
|
||||
{
|
||||
if (! $apiKey->isInGracePeriod()) {
|
||||
throw new \RuntimeException('This key is not in a grace period.');
|
||||
}
|
||||
|
||||
$apiKey->endGracePeriod();
|
||||
|
||||
Log::info('API key grace period ended', [
|
||||
'key_id' => $apiKey->id,
|
||||
'workspace_id' => $apiKey->workspace_id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up keys with expired grace periods.
|
||||
*
|
||||
* This should be called by a scheduled command to revoke
|
||||
* old keys after their grace period has ended.
|
||||
*
|
||||
* @return int Number of keys cleaned up
|
||||
*/
|
||||
public function cleanupExpiredGracePeriods(): int
|
||||
{
|
||||
$keys = ApiKey::gracePeriodExpired()
|
||||
->whereNull('deleted_at')
|
||||
->get();
|
||||
|
||||
$count = 0;
|
||||
|
||||
foreach ($keys as $key) {
|
||||
$key->revoke();
|
||||
$count++;
|
||||
|
||||
Log::info('Cleaned up API key after grace period', [
|
||||
'key_id' => $key->id,
|
||||
'workspace_id' => $key->workspace_id,
|
||||
]);
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update API key scopes.
|
||||
*/
|
||||
public function updateScopes(ApiKey $apiKey, array $scopes): void
|
||||
{
|
||||
// Validate scopes
|
||||
$validScopes = array_intersect($scopes, ApiKey::ALL_SCOPES);
|
||||
|
||||
if (empty($validScopes)) {
|
||||
throw new \InvalidArgumentException('At least one valid scope must be provided.');
|
||||
}
|
||||
|
||||
$apiKey->update(['scopes' => array_values($validScopes)]);
|
||||
|
||||
Log::info('API key scopes updated', [
|
||||
'key_id' => $apiKey->id,
|
||||
'scopes' => $validScopes,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update API key server scopes.
|
||||
*/
|
||||
public function updateServerScopes(ApiKey $apiKey, ?array $serverScopes): void
|
||||
{
|
||||
$apiKey->update(['server_scopes' => $serverScopes]);
|
||||
|
||||
Log::info('API key server scopes updated', [
|
||||
'key_id' => $apiKey->id,
|
||||
'server_scopes' => $serverScopes,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename an API key.
|
||||
*/
|
||||
public function rename(ApiKey $apiKey, string $name): void
|
||||
{
|
||||
$apiKey->update(['name' => $name]);
|
||||
|
||||
Log::info('API key renamed', [
|
||||
'key_id' => $apiKey->id,
|
||||
'name' => $name,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics for a workspace's API keys.
|
||||
*/
|
||||
public function getStats(int $workspaceId): array
|
||||
{
|
||||
$keys = ApiKey::forWorkspace($workspaceId);
|
||||
|
||||
return [
|
||||
'total' => (clone $keys)->count(),
|
||||
'active' => (clone $keys)->active()->count(),
|
||||
'expired' => (clone $keys)->expired()->count(),
|
||||
'in_grace_period' => (clone $keys)->inGracePeriod()->count(),
|
||||
'revoked' => ApiKey::withTrashed()
|
||||
->forWorkspace($workspaceId)
|
||||
->whereNotNull('deleted_at')
|
||||
->count(),
|
||||
];
|
||||
}
|
||||
}
|
||||
361
packages/core-api/src/Mod/Api/Services/ApiUsageService.php
Normal file
361
packages/core-api/src/Mod/Api/Services/ApiUsageService.php
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Api\Services;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Mod\Api\Models\ApiUsage;
|
||||
use Mod\Api\Models\ApiUsageDaily;
|
||||
|
||||
/**
|
||||
* API Usage Service - tracks and reports API usage metrics.
|
||||
*
|
||||
* Provides methods for recording API calls and generating reports.
|
||||
*/
|
||||
class ApiUsageService
|
||||
{
|
||||
/**
|
||||
* Record an API request.
|
||||
*/
|
||||
public function record(
|
||||
int $apiKeyId,
|
||||
int $workspaceId,
|
||||
string $endpoint,
|
||||
string $method,
|
||||
int $statusCode,
|
||||
int $responseTimeMs,
|
||||
?int $requestSize = null,
|
||||
?int $responseSize = null,
|
||||
?string $ipAddress = null,
|
||||
?string $userAgent = null
|
||||
): ApiUsage {
|
||||
// Normalise endpoint (remove query strings, IDs)
|
||||
$normalisedEndpoint = $this->normaliseEndpoint($endpoint);
|
||||
|
||||
// Record individual usage
|
||||
$usage = ApiUsage::record(
|
||||
$apiKeyId,
|
||||
$workspaceId,
|
||||
$normalisedEndpoint,
|
||||
$method,
|
||||
$statusCode,
|
||||
$responseTimeMs,
|
||||
$requestSize,
|
||||
$responseSize,
|
||||
$ipAddress,
|
||||
$userAgent
|
||||
);
|
||||
|
||||
// Update daily aggregation
|
||||
ApiUsageDaily::recordFromUsage($usage);
|
||||
|
||||
return $usage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage summary for a workspace.
|
||||
*/
|
||||
public function getWorkspaceSummary(
|
||||
int $workspaceId,
|
||||
?Carbon $startDate = null,
|
||||
?Carbon $endDate = null
|
||||
): array {
|
||||
$startDate = $startDate ?? now()->subDays(30);
|
||||
$endDate = $endDate ?? now();
|
||||
|
||||
$query = ApiUsageDaily::forWorkspace($workspaceId)
|
||||
->between($startDate, $endDate);
|
||||
|
||||
$totals = (clone $query)->selectRaw('
|
||||
SUM(request_count) as total_requests,
|
||||
SUM(success_count) as total_success,
|
||||
SUM(error_count) as total_errors,
|
||||
SUM(total_response_time_ms) as total_response_time,
|
||||
MIN(min_response_time_ms) as min_response_time,
|
||||
MAX(max_response_time_ms) as max_response_time,
|
||||
SUM(total_request_size) as total_request_size,
|
||||
SUM(total_response_size) as total_response_size
|
||||
')->first();
|
||||
|
||||
$totalRequests = (int) ($totals->total_requests ?? 0);
|
||||
$totalSuccess = (int) ($totals->total_success ?? 0);
|
||||
|
||||
return [
|
||||
'period' => [
|
||||
'start' => $startDate->toIso8601String(),
|
||||
'end' => $endDate->toIso8601String(),
|
||||
],
|
||||
'totals' => [
|
||||
'requests' => $totalRequests,
|
||||
'success' => $totalSuccess,
|
||||
'errors' => (int) ($totals->total_errors ?? 0),
|
||||
'success_rate' => $totalRequests > 0
|
||||
? round(($totalSuccess / $totalRequests) * 100, 2)
|
||||
: 100,
|
||||
],
|
||||
'response_time' => [
|
||||
'average_ms' => $totalRequests > 0
|
||||
? round((int) $totals->total_response_time / $totalRequests, 2)
|
||||
: 0,
|
||||
'min_ms' => (int) ($totals->min_response_time ?? 0),
|
||||
'max_ms' => (int) ($totals->max_response_time ?? 0),
|
||||
],
|
||||
'data_transfer' => [
|
||||
'request_bytes' => (int) ($totals->total_request_size ?? 0),
|
||||
'response_bytes' => (int) ($totals->total_response_size ?? 0),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage summary for a specific API key.
|
||||
*/
|
||||
public function getKeySummary(
|
||||
int $apiKeyId,
|
||||
?Carbon $startDate = null,
|
||||
?Carbon $endDate = null
|
||||
): array {
|
||||
$startDate = $startDate ?? now()->subDays(30);
|
||||
$endDate = $endDate ?? now();
|
||||
|
||||
$query = ApiUsageDaily::forKey($apiKeyId)
|
||||
->between($startDate, $endDate);
|
||||
|
||||
$totals = (clone $query)->selectRaw('
|
||||
SUM(request_count) as total_requests,
|
||||
SUM(success_count) as total_success,
|
||||
SUM(error_count) as total_errors,
|
||||
SUM(total_response_time_ms) as total_response_time,
|
||||
MIN(min_response_time_ms) as min_response_time,
|
||||
MAX(max_response_time_ms) as max_response_time
|
||||
')->first();
|
||||
|
||||
$totalRequests = (int) ($totals->total_requests ?? 0);
|
||||
$totalSuccess = (int) ($totals->total_success ?? 0);
|
||||
|
||||
return [
|
||||
'period' => [
|
||||
'start' => $startDate->toIso8601String(),
|
||||
'end' => $endDate->toIso8601String(),
|
||||
],
|
||||
'totals' => [
|
||||
'requests' => $totalRequests,
|
||||
'success' => $totalSuccess,
|
||||
'errors' => (int) ($totals->total_errors ?? 0),
|
||||
'success_rate' => $totalRequests > 0
|
||||
? round(($totalSuccess / $totalRequests) * 100, 2)
|
||||
: 100,
|
||||
],
|
||||
'response_time' => [
|
||||
'average_ms' => $totalRequests > 0
|
||||
? round((int) $totals->total_response_time / $totalRequests, 2)
|
||||
: 0,
|
||||
'min_ms' => (int) ($totals->min_response_time ?? 0),
|
||||
'max_ms' => (int) ($totals->max_response_time ?? 0),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get daily usage chart data.
|
||||
*/
|
||||
public function getDailyChart(
|
||||
int $workspaceId,
|
||||
?Carbon $startDate = null,
|
||||
?Carbon $endDate = null
|
||||
): array {
|
||||
$startDate = $startDate ?? now()->subDays(30);
|
||||
$endDate = $endDate ?? now();
|
||||
|
||||
$data = ApiUsageDaily::forWorkspace($workspaceId)
|
||||
->between($startDate, $endDate)
|
||||
->selectRaw('
|
||||
date,
|
||||
SUM(request_count) as requests,
|
||||
SUM(success_count) as success,
|
||||
SUM(error_count) as errors,
|
||||
SUM(total_response_time_ms) / NULLIF(SUM(request_count), 0) as avg_response_time
|
||||
')
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
return $data->map(fn ($row) => [
|
||||
'date' => $row->date->toDateString(),
|
||||
'requests' => (int) $row->requests,
|
||||
'success' => (int) $row->success,
|
||||
'errors' => (int) $row->errors,
|
||||
'avg_response_time_ms' => round((float) ($row->avg_response_time ?? 0), 2),
|
||||
])->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top endpoints by request count.
|
||||
*/
|
||||
public function getTopEndpoints(
|
||||
int $workspaceId,
|
||||
int $limit = 10,
|
||||
?Carbon $startDate = null,
|
||||
?Carbon $endDate = null
|
||||
): array {
|
||||
$startDate = $startDate ?? now()->subDays(30);
|
||||
$endDate = $endDate ?? now();
|
||||
|
||||
return ApiUsageDaily::forWorkspace($workspaceId)
|
||||
->between($startDate, $endDate)
|
||||
->selectRaw('
|
||||
endpoint,
|
||||
method,
|
||||
SUM(request_count) as requests,
|
||||
SUM(success_count) as success,
|
||||
SUM(error_count) as errors,
|
||||
SUM(total_response_time_ms) / NULLIF(SUM(request_count), 0) as avg_response_time
|
||||
')
|
||||
->groupBy('endpoint', 'method')
|
||||
->orderByDesc('requests')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn ($row) => [
|
||||
'endpoint' => $row->endpoint,
|
||||
'method' => $row->method,
|
||||
'requests' => (int) $row->requests,
|
||||
'success' => (int) $row->success,
|
||||
'errors' => (int) $row->errors,
|
||||
'success_rate' => $row->requests > 0
|
||||
? round(($row->success / $row->requests) * 100, 2)
|
||||
: 100,
|
||||
'avg_response_time_ms' => round((float) ($row->avg_response_time ?? 0), 2),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error breakdown by status code.
|
||||
*/
|
||||
public function getErrorBreakdown(
|
||||
int $workspaceId,
|
||||
?Carbon $startDate = null,
|
||||
?Carbon $endDate = null
|
||||
): array {
|
||||
$startDate = $startDate ?? now()->subDays(30);
|
||||
$endDate = $endDate ?? now();
|
||||
|
||||
return ApiUsage::forWorkspace($workspaceId)
|
||||
->between($startDate, $endDate)
|
||||
->where('status_code', '>=', 400)
|
||||
->selectRaw('status_code, COUNT(*) as count')
|
||||
->groupBy('status_code')
|
||||
->orderByDesc('count')
|
||||
->get()
|
||||
->map(fn ($row) => [
|
||||
'status_code' => $row->status_code,
|
||||
'count' => (int) $row->count,
|
||||
'description' => $this->getStatusCodeDescription($row->status_code),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API key usage comparison.
|
||||
*/
|
||||
public function getKeyComparison(
|
||||
int $workspaceId,
|
||||
?Carbon $startDate = null,
|
||||
?Carbon $endDate = null
|
||||
): array {
|
||||
$startDate = $startDate ?? now()->subDays(30);
|
||||
$endDate = $endDate ?? now();
|
||||
|
||||
$aggregated = ApiUsageDaily::forWorkspace($workspaceId)
|
||||
->between($startDate, $endDate)
|
||||
->selectRaw('
|
||||
api_key_id,
|
||||
SUM(request_count) as requests,
|
||||
SUM(success_count) as success,
|
||||
SUM(error_count) as errors,
|
||||
SUM(total_response_time_ms) / NULLIF(SUM(request_count), 0) as avg_response_time
|
||||
')
|
||||
->groupBy('api_key_id')
|
||||
->orderByDesc('requests')
|
||||
->get();
|
||||
|
||||
// Fetch API keys separately to avoid broken eager loading with aggregation
|
||||
$apiKeyIds = $aggregated->pluck('api_key_id')->filter()->unique()->all();
|
||||
$apiKeys = \Mod\Api\Models\ApiKey::whereIn('id', $apiKeyIds)
|
||||
->select('id', 'name', 'prefix')
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
return $aggregated->map(fn ($row) => [
|
||||
'api_key_id' => $row->api_key_id,
|
||||
'api_key_name' => $apiKeys->get($row->api_key_id)?->name ?? 'Unknown',
|
||||
'api_key_prefix' => $apiKeys->get($row->api_key_id)?->prefix ?? 'N/A',
|
||||
'requests' => (int) $row->requests,
|
||||
'success' => (int) $row->success,
|
||||
'errors' => (int) $row->errors,
|
||||
'success_rate' => $row->requests > 0
|
||||
? round(($row->success / $row->requests) * 100, 2)
|
||||
: 100,
|
||||
'avg_response_time_ms' => round((float) ($row->avg_response_time ?? 0), 2),
|
||||
])->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise endpoint path for aggregation.
|
||||
*
|
||||
* Replaces dynamic IDs with placeholders for consistent grouping.
|
||||
*/
|
||||
protected function normaliseEndpoint(string $endpoint): string
|
||||
{
|
||||
// Remove query string
|
||||
$path = parse_url($endpoint, PHP_URL_PATH) ?? $endpoint;
|
||||
|
||||
// Replace numeric IDs with {id} placeholder
|
||||
$normalised = preg_replace('/\/\d+/', '/{id}', $path);
|
||||
|
||||
// Replace UUIDs with {uuid} placeholder
|
||||
$normalised = preg_replace(
|
||||
'/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i',
|
||||
'/{uuid}',
|
||||
$normalised
|
||||
);
|
||||
|
||||
return $normalised ?? $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable status code description.
|
||||
*/
|
||||
protected function getStatusCodeDescription(int $statusCode): string
|
||||
{
|
||||
return match ($statusCode) {
|
||||
400 => 'Bad Request',
|
||||
401 => 'Unauthorised',
|
||||
403 => 'Forbidden',
|
||||
404 => 'Not Found',
|
||||
405 => 'Method Not Allowed',
|
||||
422 => 'Validation Failed',
|
||||
429 => 'Rate Limit Exceeded',
|
||||
500 => 'Internal Server Error',
|
||||
502 => 'Bad Gateway',
|
||||
503 => 'Service Unavailable',
|
||||
504 => 'Gateway Timeout',
|
||||
default => 'Error',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune old detailed usage records.
|
||||
*
|
||||
* Keeps aggregated daily data but removes detailed logs older than retention period.
|
||||
*
|
||||
* @return int Number of records deleted
|
||||
*/
|
||||
public function pruneOldRecords(int $retentionDays = 30): int
|
||||
{
|
||||
$cutoff = now()->subDays($retentionDays);
|
||||
|
||||
return ApiUsage::where('created_at', '<', $cutoff)->delete();
|
||||
}
|
||||
}
|
||||
192
packages/core-api/src/Mod/Api/Services/WebhookService.php
Normal file
192
packages/core-api/src/Mod/Api/Services/WebhookService.php
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Api\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Mod\Api\Jobs\DeliverWebhookJob;
|
||||
use Mod\Api\Models\WebhookDelivery;
|
||||
use Mod\Api\Models\WebhookEndpoint;
|
||||
|
||||
/**
|
||||
* Webhook Service - dispatches events to registered webhook endpoints.
|
||||
*
|
||||
* Finds all active endpoints subscribed to an event type and queues
|
||||
* delivery jobs with proper payload formatting and signature generation.
|
||||
*/
|
||||
class WebhookService
|
||||
{
|
||||
/**
|
||||
* Dispatch an event to all subscribed webhook endpoints.
|
||||
*
|
||||
* @param int $workspaceId The workspace that owns the webhooks
|
||||
* @param string $eventType The event type (e.g., 'bio.created')
|
||||
* @param array $data The event payload data
|
||||
* @return array<WebhookDelivery> The created delivery records
|
||||
*/
|
||||
public function dispatch(int $workspaceId, string $eventType, array $data): array
|
||||
{
|
||||
// Find all active endpoints for this workspace that subscribe to this event
|
||||
$endpoints = WebhookEndpoint::query()
|
||||
->forWorkspace($workspaceId)
|
||||
->active()
|
||||
->forEvent($eventType)
|
||||
->get();
|
||||
|
||||
if ($endpoints->isEmpty()) {
|
||||
Log::debug('No webhook endpoints found for event', [
|
||||
'workspace_id' => $workspaceId,
|
||||
'event_type' => $eventType,
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$deliveries = [];
|
||||
|
||||
// Wrap all deliveries in a transaction to ensure atomicity
|
||||
DB::transaction(function () use ($endpoints, $eventType, $data, $workspaceId, &$deliveries) {
|
||||
foreach ($endpoints as $endpoint) {
|
||||
// Create delivery record
|
||||
$delivery = WebhookDelivery::createForEvent(
|
||||
$endpoint,
|
||||
$eventType,
|
||||
$data,
|
||||
$workspaceId
|
||||
);
|
||||
|
||||
$deliveries[] = $delivery;
|
||||
|
||||
// Queue the delivery job after the transaction commits
|
||||
DeliverWebhookJob::dispatch($delivery)->afterCommit();
|
||||
|
||||
Log::info('Webhook delivery queued', [
|
||||
'delivery_id' => $delivery->id,
|
||||
'endpoint_id' => $endpoint->id,
|
||||
'event_type' => $eventType,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return $deliveries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a specific failed delivery.
|
||||
*
|
||||
* @return bool True if retry was queued, false if not eligible
|
||||
*/
|
||||
public function retry(WebhookDelivery $delivery): bool
|
||||
{
|
||||
if (! $delivery->canRetry()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($delivery) {
|
||||
// Reset status for manual retry but preserve attempt history
|
||||
$delivery->update([
|
||||
'status' => WebhookDelivery::STATUS_PENDING,
|
||||
'next_retry_at' => null,
|
||||
]);
|
||||
|
||||
DeliverWebhookJob::dispatch($delivery)->afterCommit();
|
||||
|
||||
Log::info('Manual webhook retry queued', [
|
||||
'delivery_id' => $delivery->id,
|
||||
'attempt' => $delivery->attempt,
|
||||
]);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all pending and retryable deliveries.
|
||||
*
|
||||
* This method is typically called by a scheduled command.
|
||||
*
|
||||
* @return int Number of deliveries queued
|
||||
*/
|
||||
public function processQueue(): int
|
||||
{
|
||||
$count = 0;
|
||||
|
||||
// Process deliveries one at a time with row locking to prevent race conditions
|
||||
$deliveryIds = WebhookDelivery::query()
|
||||
->needsDelivery()
|
||||
->limit(100)
|
||||
->pluck('id');
|
||||
|
||||
foreach ($deliveryIds as $deliveryId) {
|
||||
DB::transaction(function () use ($deliveryId, &$count) {
|
||||
// Lock the row for update to prevent concurrent processing
|
||||
$delivery = WebhookDelivery::query()
|
||||
->with('endpoint')
|
||||
->where('id', $deliveryId)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $delivery) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if already being processed (status changed since initial query)
|
||||
if (! in_array($delivery->status, [WebhookDelivery::STATUS_PENDING, WebhookDelivery::STATUS_RETRYING])) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle inactive endpoints by cancelling the delivery
|
||||
if (! $delivery->endpoint?->shouldReceive($delivery->event_type)) {
|
||||
$delivery->update(['status' => WebhookDelivery::STATUS_CANCELLED]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as queued to prevent duplicate processing
|
||||
$delivery->update(['status' => WebhookDelivery::STATUS_QUEUED]);
|
||||
|
||||
DeliverWebhookJob::dispatch($delivery)->afterCommit();
|
||||
$count++;
|
||||
});
|
||||
}
|
||||
|
||||
if ($count > 0) {
|
||||
Log::info('Processed webhook queue', ['count' => $count]);
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get delivery statistics for a workspace.
|
||||
*/
|
||||
public function getStats(int $workspaceId): array
|
||||
{
|
||||
$endpointIds = WebhookEndpoint::query()
|
||||
->forWorkspace($workspaceId)
|
||||
->pluck('id');
|
||||
|
||||
if ($endpointIds->isEmpty()) {
|
||||
return [
|
||||
'total' => 0,
|
||||
'pending' => 0,
|
||||
'success' => 0,
|
||||
'failed' => 0,
|
||||
'retrying' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$deliveries = WebhookDelivery::query()
|
||||
->whereIn('webhook_endpoint_id', $endpointIds);
|
||||
|
||||
return [
|
||||
'total' => (clone $deliveries)->count(),
|
||||
'pending' => (clone $deliveries)->where('status', WebhookDelivery::STATUS_PENDING)->count(),
|
||||
'success' => (clone $deliveries)->where('status', WebhookDelivery::STATUS_SUCCESS)->count(),
|
||||
'failed' => (clone $deliveries)->where('status', WebhookDelivery::STATUS_FAILED)->count(),
|
||||
'retrying' => (clone $deliveries)->where('status', WebhookDelivery::STATUS_RETRYING)->count(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Mod\Api\Models\ApiKey;
|
||||
use Mod\Api\Services\ApiKeyService;
|
||||
use Mod\Tenant\Models\User;
|
||||
use Mod\Tenant\Models\Workspace;
|
||||
|
||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create();
|
||||
$this->workspace->users()->attach($this->user->id, [
|
||||
'role' => 'owner',
|
||||
'is_default' => true,
|
||||
]);
|
||||
$this->service = app(ApiKeyService::class);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// API Key Rotation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('API Key Rotation', function () {
|
||||
it('rotates a key creating new key with same settings', function () {
|
||||
$original = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Original Key',
|
||||
[ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]
|
||||
);
|
||||
|
||||
$result = $this->service->rotate($original['api_key']);
|
||||
|
||||
expect($result)->toHaveKeys(['api_key', 'plain_key', 'old_key']);
|
||||
expect($result['api_key']->name)->toBe('Original Key');
|
||||
expect($result['api_key']->scopes)->toBe([ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]);
|
||||
expect($result['api_key']->workspace_id)->toBe($this->workspace->id);
|
||||
expect($result['api_key']->rotated_from_id)->toBe($original['api_key']->id);
|
||||
});
|
||||
|
||||
it('sets grace period on old key during rotation', function () {
|
||||
$original = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Grace Period Key'
|
||||
);
|
||||
|
||||
$result = $this->service->rotate($original['api_key'], 24);
|
||||
|
||||
$oldKey = $result['old_key']->fresh();
|
||||
expect($oldKey->grace_period_ends_at)->not->toBeNull();
|
||||
expect($oldKey->isInGracePeriod())->toBeTrue();
|
||||
});
|
||||
|
||||
it('old key remains valid during grace period', function () {
|
||||
$original = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Still Valid Key'
|
||||
);
|
||||
|
||||
$this->service->rotate($original['api_key'], 24);
|
||||
|
||||
// Old key should still be findable
|
||||
$foundKey = ApiKey::findByPlainKey($original['plain_key']);
|
||||
expect($foundKey)->not->toBeNull();
|
||||
expect($foundKey->id)->toBe($original['api_key']->id);
|
||||
});
|
||||
|
||||
it('old key becomes invalid after grace period expires', function () {
|
||||
$original = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Expired Grace Key'
|
||||
);
|
||||
|
||||
$original['api_key']->update([
|
||||
'grace_period_ends_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$foundKey = ApiKey::findByPlainKey($original['plain_key']);
|
||||
expect($foundKey)->toBeNull();
|
||||
});
|
||||
|
||||
it('prevents rotating key already in grace period', function () {
|
||||
$original = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Already Rotating Key'
|
||||
);
|
||||
|
||||
$this->service->rotate($original['api_key']);
|
||||
|
||||
expect(fn () => $this->service->rotate($original['api_key']->fresh()))
|
||||
->toThrow(\RuntimeException::class);
|
||||
});
|
||||
|
||||
it('can end grace period early', function () {
|
||||
$original = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Early End Key'
|
||||
);
|
||||
|
||||
$this->service->rotate($original['api_key'], 24);
|
||||
$this->service->endGracePeriod($original['api_key']->fresh());
|
||||
|
||||
expect($original['api_key']->fresh()->trashed())->toBeTrue();
|
||||
});
|
||||
|
||||
it('preserves server scopes during rotation', function () {
|
||||
$original = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Server Scoped Key'
|
||||
);
|
||||
$original['api_key']->update(['server_scopes' => ['commerce', 'biohost']]);
|
||||
|
||||
$result = $this->service->rotate($original['api_key']->fresh());
|
||||
|
||||
expect($result['api_key']->server_scopes)->toBe(['commerce', 'biohost']);
|
||||
});
|
||||
|
||||
it('cleans up keys with expired grace periods', function () {
|
||||
// Create keys with expired grace periods
|
||||
$key1 = ApiKey::generate($this->workspace->id, $this->user->id, 'Expired 1');
|
||||
$key1['api_key']->update(['grace_period_ends_at' => now()->subDay()]);
|
||||
|
||||
$key2 = ApiKey::generate($this->workspace->id, $this->user->id, 'Expired 2');
|
||||
$key2['api_key']->update(['grace_period_ends_at' => now()->subHour()]);
|
||||
|
||||
// Create key still in grace period
|
||||
$key3 = ApiKey::generate($this->workspace->id, $this->user->id, 'Still Active');
|
||||
$key3['api_key']->update(['grace_period_ends_at' => now()->addDay()]);
|
||||
|
||||
$cleaned = $this->service->cleanupExpiredGracePeriods();
|
||||
|
||||
expect($cleaned)->toBe(2);
|
||||
expect($key1['api_key']->fresh()->trashed())->toBeTrue();
|
||||
expect($key2['api_key']->fresh()->trashed())->toBeTrue();
|
||||
expect($key3['api_key']->fresh()->trashed())->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// API Key Scopes via Service
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('API Key Service Scopes', function () {
|
||||
it('updates key scopes', function () {
|
||||
$result = $this->service->create(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Scoped Key'
|
||||
);
|
||||
|
||||
$this->service->updateScopes($result['api_key'], [ApiKey::SCOPE_READ]);
|
||||
|
||||
expect($result['api_key']->fresh()->scopes)->toBe([ApiKey::SCOPE_READ]);
|
||||
});
|
||||
|
||||
it('requires at least one valid scope', function () {
|
||||
$result = $this->service->create(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Invalid Scopes Key'
|
||||
);
|
||||
|
||||
expect(fn () => $this->service->updateScopes($result['api_key'], ['invalid']))
|
||||
->toThrow(\InvalidArgumentException::class);
|
||||
});
|
||||
|
||||
it('updates server scopes', function () {
|
||||
$result = $this->service->create(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Server Scoped Key'
|
||||
);
|
||||
|
||||
$this->service->updateServerScopes($result['api_key'], ['commerce']);
|
||||
|
||||
expect($result['api_key']->fresh()->server_scopes)->toBe(['commerce']);
|
||||
});
|
||||
|
||||
it('clears server scopes with null', function () {
|
||||
$result = $this->service->create(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Clear Server Scopes Key',
|
||||
serverScopes: ['commerce']
|
||||
);
|
||||
|
||||
$this->service->updateServerScopes($result['api_key'], null);
|
||||
|
||||
expect($result['api_key']->fresh()->server_scopes)->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// API Key Service Limits
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('API Key Service Limits', function () {
|
||||
it('enforces max keys per workspace limit', function () {
|
||||
config(['api.keys.max_per_workspace' => 2]);
|
||||
|
||||
$this->service->create($this->workspace->id, $this->user->id, 'Key 1');
|
||||
$this->service->create($this->workspace->id, $this->user->id, 'Key 2');
|
||||
|
||||
expect(fn () => $this->service->create($this->workspace->id, $this->user->id, 'Key 3'))
|
||||
->toThrow(\RuntimeException::class);
|
||||
});
|
||||
|
||||
it('returns workspace key statistics', function () {
|
||||
$key1 = $this->service->create($this->workspace->id, $this->user->id, 'Active Key');
|
||||
$key2 = $this->service->create($this->workspace->id, $this->user->id, 'Expired Key');
|
||||
$key2['api_key']->update(['expires_at' => now()->subDay()]);
|
||||
|
||||
$key3 = $this->service->create($this->workspace->id, $this->user->id, 'Rotating Key');
|
||||
$this->service->rotate($key3['api_key']);
|
||||
|
||||
$stats = $this->service->getStats($this->workspace->id);
|
||||
|
||||
expect($stats)->toHaveKeys(['total', 'active', 'expired', 'in_grace_period', 'revoked']);
|
||||
expect($stats['total'])->toBe(4); // 3 original + 1 rotated
|
||||
expect($stats['expired'])->toBe(1);
|
||||
expect($stats['in_grace_period'])->toBe(1);
|
||||
});
|
||||
});
|
||||
602
packages/core-api/src/Mod/Api/Tests/Feature/ApiKeyTest.php
Normal file
602
packages/core-api/src/Mod/Api/Tests/Feature/ApiKeyTest.php
Normal file
|
|
@ -0,0 +1,602 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Mod\Api\Database\Factories\ApiKeyFactory;
|
||||
use Mod\Api\Models\ApiKey;
|
||||
use Mod\Tenant\Models\User;
|
||||
use Mod\Tenant\Models\Workspace;
|
||||
|
||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Cache::flush();
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create();
|
||||
$this->workspace->users()->attach($this->user->id, [
|
||||
'role' => 'owner',
|
||||
'is_default' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// API Key Creation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('API Key Creation', function () {
|
||||
it('generates a new API key with correct format', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Test API Key'
|
||||
);
|
||||
|
||||
expect($result)->toHaveKeys(['api_key', 'plain_key']);
|
||||
expect($result['api_key'])->toBeInstanceOf(ApiKey::class);
|
||||
expect($result['plain_key'])->toStartWith('hk_');
|
||||
|
||||
// Plain key format: hk_xxxxxxxx_xxxx...
|
||||
$parts = explode('_', $result['plain_key']);
|
||||
expect($parts)->toHaveCount(3);
|
||||
expect($parts[0])->toBe('hk');
|
||||
expect(strlen($parts[1]))->toBe(8);
|
||||
expect(strlen($parts[2]))->toBe(48);
|
||||
});
|
||||
|
||||
it('creates key with default read and write scopes', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Default Scopes Key'
|
||||
);
|
||||
|
||||
expect($result['api_key']->scopes)->toBe([ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]);
|
||||
});
|
||||
|
||||
it('creates key with custom scopes', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Full Access Key',
|
||||
[ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE, ApiKey::SCOPE_DELETE]
|
||||
);
|
||||
|
||||
expect($result['api_key']->scopes)->toBe(ApiKey::ALL_SCOPES);
|
||||
});
|
||||
|
||||
it('creates key with expiry date', function () {
|
||||
$expiresAt = now()->addDays(30);
|
||||
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Expiring Key',
|
||||
[ApiKey::SCOPE_READ],
|
||||
$expiresAt
|
||||
);
|
||||
|
||||
expect($result['api_key']->expires_at)->not->toBeNull();
|
||||
expect($result['api_key']->expires_at->timestamp)->toBe($expiresAt->timestamp);
|
||||
});
|
||||
|
||||
it('stores key as hashed value', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Hashed Key'
|
||||
);
|
||||
|
||||
// Extract the key part from plain key
|
||||
$parts = explode('_', $result['plain_key'], 3);
|
||||
$keyPart = $parts[2];
|
||||
|
||||
// The stored key should be the SHA-256 hash
|
||||
expect($result['api_key']->key)->toBe(hash('sha256', $keyPart));
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// API Key Authentication
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('API Key Authentication', function () {
|
||||
it('finds key by valid plain key', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Findable Key'
|
||||
);
|
||||
|
||||
$foundKey = ApiKey::findByPlainKey($result['plain_key']);
|
||||
|
||||
expect($foundKey)->not->toBeNull();
|
||||
expect($foundKey->id)->toBe($result['api_key']->id);
|
||||
});
|
||||
|
||||
it('returns null for invalid key format', function () {
|
||||
expect(ApiKey::findByPlainKey('invalid-key'))->toBeNull();
|
||||
expect(ApiKey::findByPlainKey('hk_only_two_parts'))->toBeNull();
|
||||
expect(ApiKey::findByPlainKey(''))->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-existent key', function () {
|
||||
$result = ApiKey::findByPlainKey('hk_nonexist_'.str_repeat('x', 48));
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for expired key', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Expired Key',
|
||||
[ApiKey::SCOPE_READ],
|
||||
now()->subDay() // Already expired
|
||||
);
|
||||
|
||||
$foundKey = ApiKey::findByPlainKey($result['plain_key']);
|
||||
|
||||
expect($foundKey)->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for revoked (soft-deleted) key', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Revoked Key'
|
||||
);
|
||||
|
||||
$result['api_key']->revoke();
|
||||
|
||||
$foundKey = ApiKey::findByPlainKey($result['plain_key']);
|
||||
|
||||
expect($foundKey)->toBeNull();
|
||||
});
|
||||
|
||||
it('records usage on authentication', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Tracking Key'
|
||||
);
|
||||
|
||||
expect($result['api_key']->last_used_at)->toBeNull();
|
||||
|
||||
$result['api_key']->recordUsage();
|
||||
|
||||
expect($result['api_key']->fresh()->last_used_at)->not->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Scope Checking
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Scope Checking', function () {
|
||||
it('checks for single scope', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Scoped Key',
|
||||
[ApiKey::SCOPE_READ]
|
||||
);
|
||||
|
||||
$key = $result['api_key'];
|
||||
|
||||
expect($key->hasScope(ApiKey::SCOPE_READ))->toBeTrue();
|
||||
expect($key->hasScope(ApiKey::SCOPE_WRITE))->toBeFalse();
|
||||
expect($key->hasScope(ApiKey::SCOPE_DELETE))->toBeFalse();
|
||||
});
|
||||
|
||||
it('checks for multiple scopes', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Multi-Scoped Key',
|
||||
[ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]
|
||||
);
|
||||
|
||||
$key = $result['api_key'];
|
||||
|
||||
expect($key->hasScopes([ApiKey::SCOPE_READ]))->toBeTrue();
|
||||
expect($key->hasScopes([ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]))->toBeTrue();
|
||||
expect($key->hasScopes([ApiKey::SCOPE_READ, ApiKey::SCOPE_DELETE]))->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns available scope constants', function () {
|
||||
expect(ApiKey::SCOPE_READ)->toBe('read');
|
||||
expect(ApiKey::SCOPE_WRITE)->toBe('write');
|
||||
expect(ApiKey::SCOPE_DELETE)->toBe('delete');
|
||||
expect(ApiKey::ALL_SCOPES)->toBe(['read', 'write', 'delete']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Expiry Handling
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Expiry Handling', function () {
|
||||
it('detects expired key', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Past Expiry Key',
|
||||
[ApiKey::SCOPE_READ],
|
||||
now()->subDay()
|
||||
);
|
||||
|
||||
expect($result['api_key']->isExpired())->toBeTrue();
|
||||
});
|
||||
|
||||
it('detects non-expired key', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Future Expiry Key',
|
||||
[ApiKey::SCOPE_READ],
|
||||
now()->addDay()
|
||||
);
|
||||
|
||||
expect($result['api_key']->isExpired())->toBeFalse();
|
||||
});
|
||||
|
||||
it('keys without expiry are never expired', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'No Expiry Key'
|
||||
);
|
||||
|
||||
expect($result['api_key']->expires_at)->toBeNull();
|
||||
expect($result['api_key']->isExpired())->toBeFalse();
|
||||
});
|
||||
|
||||
it('scopes expired keys correctly', function () {
|
||||
// Create expired key
|
||||
ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Expired Key 1',
|
||||
[ApiKey::SCOPE_READ],
|
||||
now()->subDays(2)
|
||||
);
|
||||
|
||||
// Create active key
|
||||
ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Active Key',
|
||||
[ApiKey::SCOPE_READ],
|
||||
now()->addDays(30)
|
||||
);
|
||||
|
||||
// Create no-expiry key
|
||||
ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'No Expiry Key'
|
||||
);
|
||||
|
||||
$expired = ApiKey::expired()->count();
|
||||
$active = ApiKey::active()->count();
|
||||
|
||||
expect($expired)->toBe(1);
|
||||
expect($active)->toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Server Scopes (MCP Access)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Server Scopes', function () {
|
||||
it('allows all servers when server_scopes is null', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'All Servers Key'
|
||||
);
|
||||
|
||||
$key = $result['api_key'];
|
||||
|
||||
expect($key->server_scopes)->toBeNull();
|
||||
expect($key->hasServerAccess('commerce'))->toBeTrue();
|
||||
expect($key->hasServerAccess('biohost'))->toBeTrue();
|
||||
expect($key->hasServerAccess('anything'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('restricts to specific servers when server_scopes is set', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Limited Servers Key'
|
||||
);
|
||||
|
||||
$key = $result['api_key'];
|
||||
$key->update(['server_scopes' => ['commerce', 'biohost']]);
|
||||
|
||||
expect($key->hasServerAccess('commerce'))->toBeTrue();
|
||||
expect($key->hasServerAccess('biohost'))->toBeTrue();
|
||||
expect($key->hasServerAccess('analytics'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns allowed servers list', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Specific Servers Key'
|
||||
);
|
||||
|
||||
$key = $result['api_key'];
|
||||
$key->update(['server_scopes' => ['commerce']]);
|
||||
|
||||
expect($key->getAllowedServers())->toBe(['commerce']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Key Revocation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Key Revocation', function () {
|
||||
it('revokes key via soft delete', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'To Be Revoked'
|
||||
);
|
||||
|
||||
$key = $result['api_key'];
|
||||
$keyId = $key->id;
|
||||
|
||||
$key->revoke();
|
||||
|
||||
// Should be soft deleted
|
||||
expect(ApiKey::find($keyId))->toBeNull();
|
||||
expect(ApiKey::withTrashed()->find($keyId))->not->toBeNull();
|
||||
});
|
||||
|
||||
it('revoked keys are excluded from workspace scope', function () {
|
||||
// Create active key
|
||||
ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Active Key'
|
||||
);
|
||||
|
||||
// Create and revoke a key
|
||||
$revokedResult = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Revoked Key'
|
||||
);
|
||||
$revokedResult['api_key']->revoke();
|
||||
|
||||
$keys = ApiKey::forWorkspace($this->workspace->id)->get();
|
||||
|
||||
expect($keys)->toHaveCount(1);
|
||||
expect($keys->first()->name)->toBe('Active Key');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Masked Key Display
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Masked Key Display', function () {
|
||||
it('provides masked key for display', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Masked Key'
|
||||
);
|
||||
|
||||
$key = $result['api_key'];
|
||||
$maskedKey = $key->masked_key;
|
||||
|
||||
expect($maskedKey)->toStartWith($key->prefix);
|
||||
expect($maskedKey)->toEndWith('_****');
|
||||
expect($maskedKey)->toBe("{$key->prefix}_****");
|
||||
});
|
||||
|
||||
it('hides raw key in JSON serialization', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Hidden Key'
|
||||
);
|
||||
|
||||
$json = $result['api_key']->toArray();
|
||||
|
||||
expect($json)->not->toHaveKey('key');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Relationships
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Relationships', function () {
|
||||
it('belongs to workspace', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Workspace Key'
|
||||
);
|
||||
|
||||
expect($result['api_key']->workspace->id)->toBe($this->workspace->id);
|
||||
});
|
||||
|
||||
it('belongs to user', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'User Key'
|
||||
);
|
||||
|
||||
expect($result['api_key']->user->id)->toBe($this->user->id);
|
||||
});
|
||||
|
||||
it('is deleted when workspace is deleted', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Cascade Key'
|
||||
);
|
||||
|
||||
$keyId = $result['api_key']->id;
|
||||
|
||||
$this->workspace->delete();
|
||||
|
||||
expect(ApiKey::withTrashed()->find($keyId))->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Factory Tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Factory', function () {
|
||||
it('creates key via factory', function () {
|
||||
$key = ApiKey::factory()
|
||||
->for($this->workspace)
|
||||
->for($this->user)
|
||||
->create();
|
||||
|
||||
expect($key)->toBeInstanceOf(ApiKey::class);
|
||||
expect($key->workspace_id)->toBe($this->workspace->id);
|
||||
expect($key->user_id)->toBe($this->user->id);
|
||||
});
|
||||
|
||||
it('creates read-only key via factory', function () {
|
||||
$key = ApiKey::factory()
|
||||
->for($this->workspace)
|
||||
->for($this->user)
|
||||
->readOnly()
|
||||
->create();
|
||||
|
||||
expect($key->scopes)->toBe([ApiKey::SCOPE_READ]);
|
||||
});
|
||||
|
||||
it('creates full access key via factory', function () {
|
||||
$key = ApiKey::factory()
|
||||
->for($this->workspace)
|
||||
->for($this->user)
|
||||
->fullAccess()
|
||||
->create();
|
||||
|
||||
expect($key->scopes)->toBe(ApiKey::ALL_SCOPES);
|
||||
});
|
||||
|
||||
it('creates expired key via factory', function () {
|
||||
$key = ApiKey::factory()
|
||||
->for($this->workspace)
|
||||
->for($this->user)
|
||||
->expired()
|
||||
->create();
|
||||
|
||||
expect($key->isExpired())->toBeTrue();
|
||||
});
|
||||
|
||||
it('creates key with known credentials via helper', function () {
|
||||
$result = ApiKeyFactory::createWithPlainKey(
|
||||
$this->workspace,
|
||||
$this->user,
|
||||
[ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]
|
||||
);
|
||||
|
||||
expect($result)->toHaveKeys(['api_key', 'plain_key']);
|
||||
|
||||
// Verify the plain key works for lookup
|
||||
$foundKey = ApiKey::findByPlainKey($result['plain_key']);
|
||||
expect($foundKey)->not->toBeNull();
|
||||
expect($foundKey->id)->toBe($result['api_key']->id);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Rate Limiting (Integration)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Rate Limiting Configuration', function () {
|
||||
it('has default rate limits configured', function () {
|
||||
$default = config('api.rate_limits.default');
|
||||
|
||||
expect($default)->toHaveKeys(['requests', 'per_minutes']);
|
||||
expect($default['requests'])->toBeInt();
|
||||
expect($default['per_minutes'])->toBeInt();
|
||||
});
|
||||
|
||||
it('has authenticated rate limits configured', function () {
|
||||
$authenticated = config('api.rate_limits.authenticated');
|
||||
|
||||
expect($authenticated)->toHaveKeys(['requests', 'per_minutes']);
|
||||
expect($authenticated['requests'])->toBeGreaterThan(config('api.rate_limits.default.requests'));
|
||||
});
|
||||
|
||||
it('has tier-based rate limits configured', function () {
|
||||
$tiers = ['starter', 'pro', 'agency', 'enterprise'];
|
||||
|
||||
foreach ($tiers as $tier) {
|
||||
$limits = config("api.rate_limits.by_tier.{$tier}");
|
||||
expect($limits)->toHaveKeys(['requests', 'per_minutes']);
|
||||
}
|
||||
});
|
||||
|
||||
it('tier limits increase with tier level', function () {
|
||||
$starter = config('api.rate_limits.by_tier.starter.requests');
|
||||
$pro = config('api.rate_limits.by_tier.pro.requests');
|
||||
$agency = config('api.rate_limits.by_tier.agency.requests');
|
||||
$enterprise = config('api.rate_limits.by_tier.enterprise.requests');
|
||||
|
||||
expect($pro)->toBeGreaterThan($starter);
|
||||
expect($agency)->toBeGreaterThan($pro);
|
||||
expect($enterprise)->toBeGreaterThan($agency);
|
||||
});
|
||||
|
||||
it('has route-level rate limit names configured', function () {
|
||||
$routeLimits = config('api.rate_limits.routes');
|
||||
|
||||
expect($routeLimits)->toBeArray();
|
||||
expect($routeLimits)->toHaveKeys(['mcp', 'pixel']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// HTTP Authentication Tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('HTTP Authentication', function () {
|
||||
it('requires authorization header', function () {
|
||||
$response = $this->getJson('/api/mcp/servers');
|
||||
|
||||
expect($response->status())->toBe(401);
|
||||
expect($response->json('error'))->toBe('unauthorized');
|
||||
});
|
||||
|
||||
it('rejects invalid API key', function () {
|
||||
$response = $this->getJson('/api/mcp/servers', [
|
||||
'Authorization' => 'Bearer hk_invalid_'.str_repeat('x', 48),
|
||||
]);
|
||||
|
||||
expect($response->status())->toBe(401);
|
||||
});
|
||||
|
||||
it('rejects expired API key via HTTP', function () {
|
||||
$result = ApiKey::generate(
|
||||
$this->workspace->id,
|
||||
$this->user->id,
|
||||
'Expired HTTP Key',
|
||||
[ApiKey::SCOPE_READ],
|
||||
now()->subDay()
|
||||
);
|
||||
|
||||
$response = $this->getJson('/api/mcp/servers', [
|
||||
'Authorization' => "Bearer {$result['plain_key']}",
|
||||
]);
|
||||
|
||||
expect($response->status())->toBe(401);
|
||||
});
|
||||
});
|
||||
362
packages/core-api/src/Mod/Api/Tests/Feature/ApiUsageTest.php
Normal file
362
packages/core-api/src/Mod/Api/Tests/Feature/ApiUsageTest.php
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Mod\Api\Models\ApiKey;
|
||||
use Mod\Api\Models\ApiUsage;
|
||||
use Mod\Api\Models\ApiUsageDaily;
|
||||
use Mod\Api\Services\ApiUsageService;
|
||||
use Mod\Tenant\Models\User;
|
||||
use Mod\Tenant\Models\Workspace;
|
||||
|
||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create();
|
||||
$this->workspace->users()->attach($this->user->id, [
|
||||
'role' => 'owner',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$result = ApiKey::generate($this->workspace->id, $this->user->id, 'Test Key');
|
||||
$this->apiKey = $result['api_key'];
|
||||
|
||||
$this->service = app(ApiUsageService::class);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Recording Usage
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Recording API Usage', function () {
|
||||
it('records individual usage entries', function () {
|
||||
$usage = $this->service->record(
|
||||
apiKeyId: $this->apiKey->id,
|
||||
workspaceId: $this->workspace->id,
|
||||
endpoint: '/api/v1/workspaces',
|
||||
method: 'GET',
|
||||
statusCode: 200,
|
||||
responseTimeMs: 150,
|
||||
requestSize: 0,
|
||||
responseSize: 1024
|
||||
);
|
||||
|
||||
expect($usage)->toBeInstanceOf(ApiUsage::class);
|
||||
expect($usage->api_key_id)->toBe($this->apiKey->id);
|
||||
expect($usage->endpoint)->toBe('/api/v1/workspaces');
|
||||
expect($usage->method)->toBe('GET');
|
||||
expect($usage->status_code)->toBe(200);
|
||||
expect($usage->response_time_ms)->toBe(150);
|
||||
});
|
||||
|
||||
it('normalises endpoint paths with IDs', function () {
|
||||
$usage = $this->service->record(
|
||||
apiKeyId: $this->apiKey->id,
|
||||
workspaceId: $this->workspace->id,
|
||||
endpoint: '/api/v1/workspaces/123/users/456',
|
||||
method: 'GET',
|
||||
statusCode: 200,
|
||||
responseTimeMs: 100
|
||||
);
|
||||
|
||||
expect($usage->endpoint)->toBe('/api/v1/workspaces/{id}/users/{id}');
|
||||
});
|
||||
|
||||
it('normalises endpoint paths with UUIDs', function () {
|
||||
$usage = $this->service->record(
|
||||
apiKeyId: $this->apiKey->id,
|
||||
workspaceId: $this->workspace->id,
|
||||
endpoint: '/api/v1/resources/550e8400-e29b-41d4-a716-446655440000',
|
||||
method: 'GET',
|
||||
statusCode: 200,
|
||||
responseTimeMs: 100
|
||||
);
|
||||
|
||||
expect($usage->endpoint)->toBe('/api/v1/resources/{uuid}');
|
||||
});
|
||||
|
||||
it('updates daily aggregation on record', function () {
|
||||
$this->service->record(
|
||||
apiKeyId: $this->apiKey->id,
|
||||
workspaceId: $this->workspace->id,
|
||||
endpoint: '/api/v1/test',
|
||||
method: 'GET',
|
||||
statusCode: 200,
|
||||
responseTimeMs: 100
|
||||
);
|
||||
|
||||
$daily = ApiUsageDaily::forKey($this->apiKey->id)
|
||||
->where('date', now()->toDateString())
|
||||
->first();
|
||||
|
||||
expect($daily)->not->toBeNull();
|
||||
expect($daily->request_count)->toBe(1);
|
||||
expect($daily->success_count)->toBe(1);
|
||||
});
|
||||
|
||||
it('increments daily counts correctly', function () {
|
||||
// Record multiple requests
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$this->service->record(
|
||||
apiKeyId: $this->apiKey->id,
|
||||
workspaceId: $this->workspace->id,
|
||||
endpoint: '/api/v1/test',
|
||||
method: 'GET',
|
||||
statusCode: 200,
|
||||
responseTimeMs: 100 + ($i * 10)
|
||||
);
|
||||
}
|
||||
|
||||
// Record some errors
|
||||
for ($i = 0; $i < 2; $i++) {
|
||||
$this->service->record(
|
||||
apiKeyId: $this->apiKey->id,
|
||||
workspaceId: $this->workspace->id,
|
||||
endpoint: '/api/v1/test',
|
||||
method: 'GET',
|
||||
statusCode: 500,
|
||||
responseTimeMs: 50
|
||||
);
|
||||
}
|
||||
|
||||
$daily = ApiUsageDaily::forKey($this->apiKey->id)
|
||||
->where('date', now()->toDateString())
|
||||
->first();
|
||||
|
||||
expect($daily->request_count)->toBe(7);
|
||||
expect($daily->success_count)->toBe(5);
|
||||
expect($daily->error_count)->toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Usage Summaries
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Usage Summaries', function () {
|
||||
beforeEach(function () {
|
||||
// Create some usage data
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$this->service->record(
|
||||
apiKeyId: $this->apiKey->id,
|
||||
workspaceId: $this->workspace->id,
|
||||
endpoint: '/api/v1/workspaces',
|
||||
method: 'GET',
|
||||
statusCode: 200,
|
||||
responseTimeMs: 100 + $i
|
||||
);
|
||||
}
|
||||
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$this->service->record(
|
||||
apiKeyId: $this->apiKey->id,
|
||||
workspaceId: $this->workspace->id,
|
||||
endpoint: '/api/v1/workspaces',
|
||||
method: 'POST',
|
||||
statusCode: 422,
|
||||
responseTimeMs: 50
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns workspace summary', function () {
|
||||
$summary = $this->service->getWorkspaceSummary($this->workspace->id);
|
||||
|
||||
expect($summary)->toHaveKeys(['period', 'totals', 'response_time', 'data_transfer']);
|
||||
expect($summary['totals']['requests'])->toBe(13);
|
||||
expect($summary['totals']['success'])->toBe(10);
|
||||
expect($summary['totals']['errors'])->toBe(3);
|
||||
});
|
||||
|
||||
it('returns key summary', function () {
|
||||
$summary = $this->service->getKeySummary($this->apiKey->id);
|
||||
|
||||
expect($summary['totals']['requests'])->toBe(13);
|
||||
expect($summary['totals']['success_rate'])->toBeGreaterThan(70);
|
||||
});
|
||||
|
||||
it('calculates average response time', function () {
|
||||
$summary = $this->service->getWorkspaceSummary($this->workspace->id);
|
||||
|
||||
// (100+101+102+...+109 + 50*3) / 13
|
||||
expect($summary['response_time']['average_ms'])->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('filters by date range', function () {
|
||||
// Create usage for 2 days ago with correct timestamp upfront
|
||||
$oldDate = now()->subDays(2);
|
||||
$usage = ApiUsage::create([
|
||||
'api_key_id' => $this->apiKey->id,
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'endpoint' => '/api/v1/old',
|
||||
'method' => 'GET',
|
||||
'status_code' => 200,
|
||||
'response_time_ms' => 100,
|
||||
'created_at' => $oldDate,
|
||||
'updated_at' => $oldDate,
|
||||
]);
|
||||
|
||||
// Also create a backdated daily aggregate for consistency
|
||||
ApiUsageDaily::updateOrCreate(
|
||||
[
|
||||
'api_key_id' => $this->apiKey->id,
|
||||
'date' => $oldDate->toDateString(),
|
||||
],
|
||||
[
|
||||
'request_count' => 1,
|
||||
'success_count' => 1,
|
||||
'error_count' => 0,
|
||||
'total_response_time_ms' => 100,
|
||||
'total_request_size' => 0,
|
||||
'total_response_size' => 0,
|
||||
]
|
||||
);
|
||||
|
||||
// Summary for last 24 hours should not include old data
|
||||
$summary = $this->service->getWorkspaceSummary(
|
||||
$this->workspace->id,
|
||||
now()->subDay(),
|
||||
now()
|
||||
);
|
||||
|
||||
expect($summary['totals']['requests'])->toBe(13); // Only today's requests
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Charts and Reports
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Charts and Reports', function () {
|
||||
beforeEach(function () {
|
||||
// Create usage spread across days
|
||||
for ($day = 0; $day < 7; $day++) {
|
||||
$date = now()->subDays($day);
|
||||
$requests = 10 - $day;
|
||||
|
||||
for ($i = 0; $i < $requests; $i++) {
|
||||
$usage = ApiUsage::record(
|
||||
$this->apiKey->id,
|
||||
$this->workspace->id,
|
||||
'/api/v1/test',
|
||||
'GET',
|
||||
200,
|
||||
100
|
||||
);
|
||||
$usage->update(['created_at' => $date]);
|
||||
|
||||
ApiUsageDaily::recordFromUsage($usage);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('returns daily chart data', function () {
|
||||
$chart = $this->service->getDailyChart($this->workspace->id);
|
||||
|
||||
expect($chart)->toBeArray();
|
||||
expect(count($chart))->toBeGreaterThan(0);
|
||||
expect($chart[0])->toHaveKeys(['date', 'requests', 'success', 'errors', 'avg_response_time_ms']);
|
||||
});
|
||||
|
||||
it('returns top endpoints', function () {
|
||||
// Add some variety
|
||||
$this->service->record(
|
||||
$this->apiKey->id,
|
||||
$this->workspace->id,
|
||||
'/api/v1/popular',
|
||||
'GET',
|
||||
200,
|
||||
100
|
||||
);
|
||||
|
||||
$endpoints = $this->service->getTopEndpoints($this->workspace->id, 5);
|
||||
|
||||
expect($endpoints)->toBeArray();
|
||||
expect($endpoints[0])->toHaveKeys(['endpoint', 'method', 'requests', 'success_rate', 'avg_response_time_ms']);
|
||||
});
|
||||
|
||||
it('returns error breakdown', function () {
|
||||
// Add some errors
|
||||
$this->service->record($this->apiKey->id, $this->workspace->id, '/api/v1/test', 'GET', 401, 50);
|
||||
$this->service->record($this->apiKey->id, $this->workspace->id, '/api/v1/test', 'GET', 404, 50);
|
||||
$this->service->record($this->apiKey->id, $this->workspace->id, '/api/v1/test', 'GET', 500, 50);
|
||||
|
||||
$errors = $this->service->getErrorBreakdown($this->workspace->id);
|
||||
|
||||
expect($errors)->toBeArray();
|
||||
expect(count($errors))->toBe(3);
|
||||
expect($errors[0])->toHaveKeys(['status_code', 'count', 'description']);
|
||||
});
|
||||
|
||||
it('returns key comparison', function () {
|
||||
// Create another key with usage
|
||||
$key2 = ApiKey::generate($this->workspace->id, $this->user->id, 'Second Key');
|
||||
$this->service->record($key2['api_key']->id, $this->workspace->id, '/api/v1/test', 'GET', 200, 100);
|
||||
|
||||
$comparison = $this->service->getKeyComparison($this->workspace->id);
|
||||
|
||||
expect($comparison)->toBeArray();
|
||||
expect(count($comparison))->toBe(2);
|
||||
expect($comparison[0])->toHaveKeys(['api_key_id', 'api_key_name', 'requests', 'success_rate']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Data Retention
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Data Retention', function () {
|
||||
it('prunes old detailed records', function () {
|
||||
// Create old records
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$usage = ApiUsage::record(
|
||||
$this->apiKey->id,
|
||||
$this->workspace->id,
|
||||
'/api/v1/old',
|
||||
'GET',
|
||||
200,
|
||||
100
|
||||
);
|
||||
$usage->update(['created_at' => now()->subDays(60)]);
|
||||
}
|
||||
|
||||
// Create recent records
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
ApiUsage::record(
|
||||
$this->apiKey->id,
|
||||
$this->workspace->id,
|
||||
'/api/v1/recent',
|
||||
'GET',
|
||||
200,
|
||||
100
|
||||
);
|
||||
}
|
||||
|
||||
$deleted = $this->service->pruneOldRecords(30);
|
||||
|
||||
expect($deleted)->toBe(5);
|
||||
expect(ApiUsage::count())->toBe(3);
|
||||
});
|
||||
|
||||
it('keeps daily aggregates when pruning detailed records', function () {
|
||||
// Create and aggregate old record
|
||||
$usage = ApiUsage::record(
|
||||
$this->apiKey->id,
|
||||
$this->workspace->id,
|
||||
'/api/v1/old',
|
||||
'GET',
|
||||
200,
|
||||
100
|
||||
);
|
||||
$usage->update(['created_at' => now()->subDays(60)]);
|
||||
ApiUsageDaily::recordFromUsage($usage);
|
||||
|
||||
$dailyCountBefore = ApiUsageDaily::count();
|
||||
|
||||
$this->service->pruneOldRecords(30);
|
||||
|
||||
// Daily aggregates should remain
|
||||
expect(ApiUsageDaily::count())->toBe($dailyCountBefore);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,340 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Mod\Api\Jobs\DeliverWebhookJob;
|
||||
use Mod\Api\Models\WebhookDelivery;
|
||||
use Mod\Api\Models\WebhookEndpoint;
|
||||
use Mod\Api\Services\WebhookService;
|
||||
use Mod\Tenant\Models\Workspace;
|
||||
|
||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Http::fake();
|
||||
|
||||
$this->workspace = Workspace::factory()->create();
|
||||
$this->service = app(WebhookService::class);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Webhook Service
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Webhook Service', function () {
|
||||
it('dispatches event to subscribed endpoints', function () {
|
||||
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||
$this->workspace->id,
|
||||
'https://example.com/webhook',
|
||||
['bio.created']
|
||||
);
|
||||
|
||||
$deliveries = $this->service->dispatch(
|
||||
$this->workspace->id,
|
||||
'bio.created',
|
||||
['bio_id' => 123, 'name' => 'Test Bio']
|
||||
);
|
||||
|
||||
expect($deliveries)->toHaveCount(1);
|
||||
expect($deliveries[0]->event_type)->toBe('bio.created');
|
||||
expect($deliveries[0]->webhook_endpoint_id)->toBe($endpoint->id);
|
||||
expect($deliveries[0]->status)->toBe(WebhookDelivery::STATUS_PENDING);
|
||||
});
|
||||
|
||||
it('does not dispatch to endpoints not subscribed to event', function () {
|
||||
WebhookEndpoint::createForWorkspace(
|
||||
$this->workspace->id,
|
||||
'https://example.com/webhook',
|
||||
['bio.updated'] // Different event
|
||||
);
|
||||
|
||||
$deliveries = $this->service->dispatch(
|
||||
$this->workspace->id,
|
||||
'bio.created',
|
||||
['bio_id' => 123]
|
||||
);
|
||||
|
||||
expect($deliveries)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('dispatches to wildcard subscribed endpoints', function () {
|
||||
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||
$this->workspace->id,
|
||||
'https://example.com/webhook',
|
||||
['*'] // Subscribe to all events
|
||||
);
|
||||
|
||||
$deliveries = $this->service->dispatch(
|
||||
$this->workspace->id,
|
||||
'any.event.type',
|
||||
['data' => 'test']
|
||||
);
|
||||
|
||||
expect($deliveries)->toHaveCount(1);
|
||||
});
|
||||
|
||||
it('does not dispatch to inactive endpoints', function () {
|
||||
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||
$this->workspace->id,
|
||||
'https://example.com/webhook',
|
||||
['bio.created']
|
||||
);
|
||||
$endpoint->update(['active' => false]);
|
||||
|
||||
$deliveries = $this->service->dispatch(
|
||||
$this->workspace->id,
|
||||
'bio.created',
|
||||
['bio_id' => 123]
|
||||
);
|
||||
|
||||
expect($deliveries)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('does not dispatch to disabled endpoints', function () {
|
||||
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||
$this->workspace->id,
|
||||
'https://example.com/webhook',
|
||||
['bio.created']
|
||||
);
|
||||
$endpoint->update(['disabled_at' => now()]);
|
||||
|
||||
$deliveries = $this->service->dispatch(
|
||||
$this->workspace->id,
|
||||
'bio.created',
|
||||
['bio_id' => 123]
|
||||
);
|
||||
|
||||
expect($deliveries)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('returns webhook stats for workspace', function () {
|
||||
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||
$this->workspace->id,
|
||||
'https://example.com/webhook',
|
||||
['bio.created']
|
||||
);
|
||||
|
||||
// Create some deliveries
|
||||
WebhookDelivery::createForEvent($endpoint, 'bio.created', ['id' => 1]);
|
||||
$delivery2 = WebhookDelivery::createForEvent($endpoint, 'bio.created', ['id' => 2]);
|
||||
$delivery2->markSuccess(200);
|
||||
$delivery3 = WebhookDelivery::createForEvent($endpoint, 'bio.created', ['id' => 3]);
|
||||
$delivery3->markFailed(500, 'Server Error');
|
||||
|
||||
$stats = $this->service->getStats($this->workspace->id);
|
||||
|
||||
expect($stats['total'])->toBe(3);
|
||||
expect($stats['pending'])->toBe(1);
|
||||
expect($stats['success'])->toBe(1);
|
||||
expect($stats['retrying'])->toBe(1); // Failed with retries remaining
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Webhook Delivery Job
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Webhook Delivery Job', function () {
|
||||
it('marks delivery as success on 2xx response', function () {
|
||||
Http::fake([
|
||||
'example.com/*' => Http::response(['received' => true], 200),
|
||||
]);
|
||||
|
||||
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||
$this->workspace->id,
|
||||
'https://example.com/webhook',
|
||||
['bio.created']
|
||||
);
|
||||
|
||||
$delivery = WebhookDelivery::createForEvent(
|
||||
$endpoint,
|
||||
'bio.created',
|
||||
['bio_id' => 123]
|
||||
);
|
||||
|
||||
$job = new DeliverWebhookJob($delivery);
|
||||
$job->handle();
|
||||
|
||||
$delivery->refresh();
|
||||
expect($delivery->status)->toBe(WebhookDelivery::STATUS_SUCCESS);
|
||||
expect($delivery->response_code)->toBe(200);
|
||||
expect($delivery->delivered_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('marks delivery as retrying on 5xx response', function () {
|
||||
Http::fake([
|
||||
'example.com/*' => Http::response('Server Error', 500),
|
||||
]);
|
||||
|
||||
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||
$this->workspace->id,
|
||||
'https://example.com/webhook',
|
||||
['bio.created']
|
||||
);
|
||||
|
||||
$delivery = WebhookDelivery::createForEvent(
|
||||
$endpoint,
|
||||
'bio.created',
|
||||
['bio_id' => 123]
|
||||
);
|
||||
|
||||
$job = new DeliverWebhookJob($delivery);
|
||||
$job->handle();
|
||||
|
||||
$delivery->refresh();
|
||||
expect($delivery->status)->toBe(WebhookDelivery::STATUS_RETRYING);
|
||||
expect($delivery->response_code)->toBe(500);
|
||||
expect($delivery->attempt)->toBe(2);
|
||||
expect($delivery->next_retry_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('marks delivery as failed after max retries', function () {
|
||||
Http::fake([
|
||||
'example.com/*' => Http::response('Server Error', 500),
|
||||
]);
|
||||
|
||||
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||
$this->workspace->id,
|
||||
'https://example.com/webhook',
|
||||
['bio.created']
|
||||
);
|
||||
|
||||
$delivery = WebhookDelivery::createForEvent(
|
||||
$endpoint,
|
||||
'bio.created',
|
||||
['bio_id' => 123]
|
||||
);
|
||||
$delivery->update(['attempt' => WebhookDelivery::MAX_RETRIES]);
|
||||
|
||||
$job = new DeliverWebhookJob($delivery);
|
||||
$job->handle();
|
||||
|
||||
$delivery->refresh();
|
||||
expect($delivery->status)->toBe(WebhookDelivery::STATUS_FAILED);
|
||||
});
|
||||
|
||||
it('includes correct signature header', function () {
|
||||
Http::fake(function ($request) {
|
||||
// Verify signature header exists
|
||||
expect($request->hasHeader('X-HostHub-Signature'))->toBeTrue();
|
||||
expect($request->hasHeader('X-HostHub-Event'))->toBeTrue();
|
||||
expect($request->hasHeader('X-HostHub-Delivery'))->toBeTrue();
|
||||
|
||||
return Http::response(['ok' => true], 200);
|
||||
});
|
||||
|
||||
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||
$this->workspace->id,
|
||||
'https://example.com/webhook',
|
||||
['bio.created']
|
||||
);
|
||||
|
||||
$delivery = WebhookDelivery::createForEvent(
|
||||
$endpoint,
|
||||
'bio.created',
|
||||
['bio_id' => 123]
|
||||
);
|
||||
|
||||
$job = new DeliverWebhookJob($delivery);
|
||||
$job->handle();
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->url() === 'https://example.com/webhook';
|
||||
});
|
||||
});
|
||||
|
||||
it('skips delivery if endpoint becomes inactive', function () {
|
||||
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||
$this->workspace->id,
|
||||
'https://example.com/webhook',
|
||||
['bio.created']
|
||||
);
|
||||
|
||||
$delivery = WebhookDelivery::createForEvent(
|
||||
$endpoint,
|
||||
'bio.created',
|
||||
['bio_id' => 123]
|
||||
);
|
||||
|
||||
// Deactivate endpoint after delivery created
|
||||
$endpoint->update(['active' => false]);
|
||||
|
||||
$job = new DeliverWebhookJob($delivery);
|
||||
$job->handle();
|
||||
|
||||
// Should not have made any HTTP requests
|
||||
Http::assertNothingSent();
|
||||
|
||||
// Delivery should remain pending (skipped)
|
||||
$delivery->refresh();
|
||||
expect($delivery->status)->toBe(WebhookDelivery::STATUS_PENDING);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Webhook Endpoint Auto-Disable
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Webhook Endpoint Auto-Disable', function () {
|
||||
it('disables endpoint after consecutive failures', function () {
|
||||
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||
$this->workspace->id,
|
||||
'https://example.com/webhook',
|
||||
['bio.created']
|
||||
);
|
||||
|
||||
// Simulate 10 consecutive failures
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$endpoint->recordFailure();
|
||||
}
|
||||
|
||||
$endpoint->refresh();
|
||||
expect($endpoint->active)->toBeFalse();
|
||||
expect($endpoint->disabled_at)->not->toBeNull();
|
||||
expect($endpoint->failure_count)->toBe(10);
|
||||
});
|
||||
|
||||
it('resets failure count on success', function () {
|
||||
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||
$this->workspace->id,
|
||||
'https://example.com/webhook',
|
||||
['bio.created']
|
||||
);
|
||||
|
||||
// Record some failures
|
||||
$endpoint->recordFailure();
|
||||
$endpoint->recordFailure();
|
||||
$endpoint->recordFailure();
|
||||
expect($endpoint->fresh()->failure_count)->toBe(3);
|
||||
|
||||
// Record success
|
||||
$endpoint->recordSuccess();
|
||||
|
||||
$endpoint->refresh();
|
||||
expect($endpoint->failure_count)->toBe(0);
|
||||
});
|
||||
|
||||
it('can be re-enabled after being disabled', function () {
|
||||
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||
$this->workspace->id,
|
||||
'https://example.com/webhook',
|
||||
['bio.created']
|
||||
);
|
||||
|
||||
// Disable it
|
||||
$endpoint->update([
|
||||
'active' => false,
|
||||
'disabled_at' => now(),
|
||||
'failure_count' => 10,
|
||||
]);
|
||||
|
||||
// Re-enable
|
||||
$endpoint->enable();
|
||||
|
||||
$endpoint->refresh();
|
||||
expect($endpoint->active)->toBeTrue();
|
||||
expect($endpoint->disabled_at)->toBeNull();
|
||||
expect($endpoint->failure_count)->toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Mcp\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Mod\Mcp\Models\McpApiRequest;
|
||||
use Mod\Mcp\Models\McpToolCall;
|
||||
use Mod\Mcp\Models\McpToolCallStat;
|
||||
|
||||
/**
|
||||
* Cleanup old MCP tool call logs and API request logs.
|
||||
*
|
||||
* Prunes records older than the configured retention period to prevent
|
||||
* unbounded table growth. Aggregated statistics are retained longer
|
||||
* than detailed logs.
|
||||
*/
|
||||
class CleanupToolCallLogsCommand extends Command
|
||||
{
|
||||
protected $signature = 'mcp:cleanup-logs
|
||||
{--days= : Override the default retention period for detailed logs}
|
||||
{--stats-days= : Override the default retention period for statistics}
|
||||
{--dry-run : Show what would be deleted without actually deleting}';
|
||||
|
||||
protected $description = 'Clean up old MCP tool call logs and API request logs';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = $this->option('dry-run');
|
||||
$logRetentionDays = (int) ($this->option('days') ?? config('mcp.log_retention.days', 90));
|
||||
$statsRetentionDays = (int) ($this->option('stats-days') ?? config('mcp.log_retention.stats_days', 365));
|
||||
|
||||
$this->info('MCP Log Cleanup'.($dryRun ? ' (DRY RUN)' : ''));
|
||||
$this->line('');
|
||||
$this->line("Detailed logs retention: {$logRetentionDays} days");
|
||||
$this->line("Statistics retention: {$statsRetentionDays} days");
|
||||
$this->line('');
|
||||
|
||||
$logsCutoff = now()->subDays($logRetentionDays);
|
||||
$statsCutoff = now()->subDays($statsRetentionDays);
|
||||
|
||||
// Clean up tool call logs
|
||||
$toolCallsCount = McpToolCall::where('created_at', '<', $logsCutoff)->count();
|
||||
if ($toolCallsCount > 0) {
|
||||
if ($dryRun) {
|
||||
$this->line("Would delete {$toolCallsCount} tool call log(s) older than {$logsCutoff->toDateString()}");
|
||||
} else {
|
||||
// Delete in chunks to avoid memory issues and lock contention
|
||||
$deleted = $this->deleteInChunks(McpToolCall::class, 'created_at', $logsCutoff);
|
||||
$this->info("Deleted {$deleted} tool call log(s)");
|
||||
}
|
||||
} else {
|
||||
$this->line('No tool call logs to clean up');
|
||||
}
|
||||
|
||||
// Clean up API request logs
|
||||
$apiRequestsCount = McpApiRequest::where('created_at', '<', $logsCutoff)->count();
|
||||
if ($apiRequestsCount > 0) {
|
||||
if ($dryRun) {
|
||||
$this->line("Would delete {$apiRequestsCount} API request log(s) older than {$logsCutoff->toDateString()}");
|
||||
} else {
|
||||
$deleted = $this->deleteInChunks(McpApiRequest::class, 'created_at', $logsCutoff);
|
||||
$this->info("Deleted {$deleted} API request log(s)");
|
||||
}
|
||||
} else {
|
||||
$this->line('No API request logs to clean up');
|
||||
}
|
||||
|
||||
// Clean up aggregated statistics (longer retention)
|
||||
$statsCount = McpToolCallStat::where('date', '<', $statsCutoff->toDateString())->count();
|
||||
if ($statsCount > 0) {
|
||||
if ($dryRun) {
|
||||
$this->line("Would delete {$statsCount} tool call stat(s) older than {$statsCutoff->toDateString()}");
|
||||
} else {
|
||||
$deleted = McpToolCallStat::where('date', '<', $statsCutoff->toDateString())->delete();
|
||||
$this->info("Deleted {$deleted} tool call stat(s)");
|
||||
}
|
||||
} else {
|
||||
$this->line('No tool call stats to clean up');
|
||||
}
|
||||
|
||||
$this->line('');
|
||||
$this->info('Cleanup complete.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete records in chunks to avoid memory issues.
|
||||
*/
|
||||
protected function deleteInChunks(string $model, string $column, \DateTimeInterface $cutoff, int $chunkSize = 1000): int
|
||||
{
|
||||
$totalDeleted = 0;
|
||||
|
||||
do {
|
||||
$deleted = $model::where($column, '<', $cutoff)
|
||||
->limit($chunkSize)
|
||||
->delete();
|
||||
|
||||
$totalDeleted += $deleted;
|
||||
|
||||
// Small pause to reduce database pressure
|
||||
if ($deleted > 0) {
|
||||
usleep(10000); // 10ms
|
||||
}
|
||||
} while ($deleted > 0);
|
||||
|
||||
return $totalDeleted;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Mcp\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Mod\Mcp\Services\McpMetricsService;
|
||||
use Mod\Mcp\Services\McpMonitoringService;
|
||||
|
||||
/**
|
||||
* MCP Monitor Command.
|
||||
*
|
||||
* Provides CLI access to MCP monitoring features including
|
||||
* health checks, metrics export, and alert checking.
|
||||
*/
|
||||
class McpMonitorCommand extends Command
|
||||
{
|
||||
protected $signature = 'mcp:monitor
|
||||
{action=status : Action to perform (status, alerts, export, report, prometheus)}
|
||||
{--days=7 : Number of days for report period}
|
||||
{--json : Output as JSON}';
|
||||
|
||||
protected $description = 'Monitor MCP tool performance and health';
|
||||
|
||||
public function handle(McpMetricsService $metrics, McpMonitoringService $monitoring): int
|
||||
{
|
||||
$action = $this->argument('action');
|
||||
|
||||
return match ($action) {
|
||||
'status' => $this->showStatus($monitoring),
|
||||
'alerts' => $this->checkAlerts($monitoring),
|
||||
'export' => $this->exportMetrics($monitoring),
|
||||
'report' => $this->showReport($monitoring),
|
||||
'prometheus' => $this->showPrometheus($monitoring),
|
||||
default => $this->showHelp(),
|
||||
};
|
||||
}
|
||||
|
||||
protected function showStatus(McpMonitoringService $monitoring): int
|
||||
{
|
||||
$health = $monitoring->getHealthStatus();
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line(json_encode($health, JSON_PRETTY_PRINT));
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$statusColor = match ($health['status']) {
|
||||
'healthy' => 'green',
|
||||
'degraded' => 'yellow',
|
||||
'critical' => 'red',
|
||||
default => 'white',
|
||||
};
|
||||
|
||||
$this->newLine();
|
||||
$this->line("<fg={$statusColor};options=bold>MCP Health Status: ".strtoupper($health['status']).'</>');
|
||||
$this->newLine();
|
||||
|
||||
$this->table(
|
||||
['Metric', 'Value'],
|
||||
[
|
||||
['Total Calls (24h)', number_format($health['metrics']['total_calls'])],
|
||||
['Success Rate', $health['metrics']['success_rate'].'%'],
|
||||
['Error Rate', $health['metrics']['error_rate'].'%'],
|
||||
['Avg Duration', $health['metrics']['avg_duration_ms'].'ms'],
|
||||
]
|
||||
);
|
||||
|
||||
if (count($health['issues']) > 0) {
|
||||
$this->newLine();
|
||||
$this->warn('Issues Detected:');
|
||||
|
||||
foreach ($health['issues'] as $issue) {
|
||||
$icon = $issue['severity'] === 'critical' ? '!!' : '!';
|
||||
$this->line(" [{$icon}] {$issue['message']}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->line('<fg=gray>Checked at: '.$health['checked_at'].'</>');
|
||||
|
||||
return $health['status'] === 'critical' ? 1 : 0;
|
||||
}
|
||||
|
||||
protected function checkAlerts(McpMonitoringService $monitoring): int
|
||||
{
|
||||
$alerts = $monitoring->checkAlerts();
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line(json_encode($alerts, JSON_PRETTY_PRINT));
|
||||
|
||||
return count($alerts) > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
if (count($alerts) === 0) {
|
||||
$this->info('No alerts detected.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->warn(count($alerts).' alert(s) detected:');
|
||||
$this->newLine();
|
||||
|
||||
foreach ($alerts as $alert) {
|
||||
$severityColor = $alert['severity'] === 'critical' ? 'red' : 'yellow';
|
||||
$this->line("<fg={$severityColor}>[{$alert['severity']}]</> {$alert['message']}");
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
protected function exportMetrics(McpMonitoringService $monitoring): int
|
||||
{
|
||||
$monitoring->exportMetrics();
|
||||
$this->info('Metrics exported to monitoring channel.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function showReport(McpMonitoringService $monitoring): int
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$report = $monitoring->getSummaryReport($days);
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line(json_encode($report, JSON_PRETTY_PRINT));
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->line("<options=bold>MCP Summary Report ({$days} days)</>");
|
||||
$this->line("Period: {$report['period']['from']} to {$report['period']['to']}");
|
||||
$this->newLine();
|
||||
|
||||
// Overview
|
||||
$this->line('<fg=cyan>Overview:</>');
|
||||
$this->table(
|
||||
['Metric', 'Value'],
|
||||
[
|
||||
['Total Calls', number_format($report['overview']['total_calls'])],
|
||||
['Success Rate', $report['overview']['success_rate'].'%'],
|
||||
['Avg Duration', $report['overview']['avg_duration_ms'].'ms'],
|
||||
['Unique Tools', $report['overview']['unique_tools']],
|
||||
['Unique Servers', $report['overview']['unique_servers']],
|
||||
]
|
||||
);
|
||||
|
||||
// Top tools
|
||||
if (count($report['top_tools']) > 0) {
|
||||
$this->newLine();
|
||||
$this->line('<fg=cyan>Top Tools:</>');
|
||||
|
||||
$toolRows = [];
|
||||
foreach ($report['top_tools'] as $tool) {
|
||||
$toolRows[] = [
|
||||
$tool->tool_name,
|
||||
number_format($tool->total_calls),
|
||||
$tool->success_rate.'%',
|
||||
round($tool->avg_duration ?? 0).'ms',
|
||||
];
|
||||
}
|
||||
|
||||
$this->table(['Tool', 'Calls', 'Success Rate', 'Avg Duration'], $toolRows);
|
||||
}
|
||||
|
||||
// Anomalies
|
||||
if (count($report['anomalies']) > 0) {
|
||||
$this->newLine();
|
||||
$this->warn('Anomalies Detected:');
|
||||
|
||||
foreach ($report['anomalies'] as $anomaly) {
|
||||
$this->line(" - [{$anomaly['tool']}] {$anomaly['message']}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->line('<fg=gray>Generated: '.$report['generated_at'].'</>');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function showPrometheus(McpMonitoringService $monitoring): int
|
||||
{
|
||||
$metrics = $monitoring->getPrometheusMetrics();
|
||||
$this->line($metrics);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function showHelp(): int
|
||||
{
|
||||
$this->error('Unknown action. Available actions: status, alerts, export, report, prometheus');
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
470
packages/core-mcp/src/Mod/Mcp/Controllers/McpApiController.php
Normal file
470
packages/core-mcp/src/Mod/Mcp/Controllers/McpApiController.php
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Api\Controllers;
|
||||
|
||||
use Core\Front\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Mod\Api\Models\ApiKey;
|
||||
use Mod\Mcp\Models\McpApiRequest;
|
||||
use Mod\Mcp\Models\McpToolCall;
|
||||
use Mod\Mcp\Services\McpWebhookDispatcher;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
/**
|
||||
* MCP HTTP API Controller.
|
||||
*
|
||||
* Provides HTTP bridge to MCP servers for external integrations.
|
||||
*/
|
||||
class McpApiController extends Controller
|
||||
{
|
||||
/**
|
||||
* List all available MCP servers.
|
||||
*
|
||||
* GET /api/v1/mcp/servers
|
||||
*/
|
||||
public function servers(Request $request): JsonResponse
|
||||
{
|
||||
$registry = $this->loadRegistry();
|
||||
|
||||
$servers = collect($registry['servers'] ?? [])
|
||||
->map(fn ($ref) => $this->loadServerSummary($ref['id']))
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'servers' => $servers,
|
||||
'count' => $servers->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server details with tools and resources.
|
||||
*
|
||||
* GET /api/v1/mcp/servers/{id}
|
||||
*/
|
||||
public function server(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$server = $this->loadServerFull($id);
|
||||
|
||||
if (! $server) {
|
||||
return response()->json(['error' => 'Server not found'], 404);
|
||||
}
|
||||
|
||||
return response()->json($server);
|
||||
}
|
||||
|
||||
/**
|
||||
* List tools for a specific server.
|
||||
*
|
||||
* GET /api/v1/mcp/servers/{id}/tools
|
||||
*/
|
||||
public function tools(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$server = $this->loadServerFull($id);
|
||||
|
||||
if (! $server) {
|
||||
return response()->json(['error' => 'Server not found'], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'server' => $id,
|
||||
'tools' => $server['tools'] ?? [],
|
||||
'count' => count($server['tools'] ?? []),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tool on an MCP server.
|
||||
*
|
||||
* POST /api/v1/mcp/tools/call
|
||||
*/
|
||||
public function callTool(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'server' => 'required|string|max:64',
|
||||
'tool' => 'required|string|max:128',
|
||||
'arguments' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$server = $this->loadServerFull($validated['server']);
|
||||
if (! $server) {
|
||||
return response()->json(['error' => 'Server not found'], 404);
|
||||
}
|
||||
|
||||
// Verify tool exists
|
||||
$toolDef = collect($server['tools'] ?? [])->firstWhere('name', $validated['tool']);
|
||||
if (! $toolDef) {
|
||||
return response()->json(['error' => 'Tool not found'], 404);
|
||||
}
|
||||
|
||||
// Validate arguments against tool's input schema
|
||||
$validationErrors = $this->validateToolArguments($toolDef, $validated['arguments'] ?? []);
|
||||
if (! empty($validationErrors)) {
|
||||
return response()->json([
|
||||
'error' => 'validation_failed',
|
||||
'message' => 'Tool arguments do not match input schema',
|
||||
'validation_errors' => $validationErrors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Get API key for logging
|
||||
$apiKey = $request->attributes->get('api_key');
|
||||
$workspace = $apiKey?->workspace;
|
||||
|
||||
$startTime = microtime(true);
|
||||
|
||||
try {
|
||||
// Execute the tool via artisan command
|
||||
$result = $this->executeToolViaArtisan(
|
||||
$validated['server'],
|
||||
$validated['tool'],
|
||||
$validated['arguments'] ?? []
|
||||
);
|
||||
|
||||
$durationMs = (int) ((microtime(true) - $startTime) * 1000);
|
||||
|
||||
// Log the call
|
||||
$this->logToolCall($apiKey, $validated, $result, $durationMs, true);
|
||||
|
||||
// Dispatch webhooks
|
||||
$this->dispatchWebhook($apiKey, $validated, true, $durationMs);
|
||||
|
||||
$response = [
|
||||
'success' => true,
|
||||
'server' => $validated['server'],
|
||||
'tool' => $validated['tool'],
|
||||
'result' => $result,
|
||||
'duration_ms' => $durationMs,
|
||||
];
|
||||
|
||||
// Log full request for debugging/replay
|
||||
$this->logApiRequest($request, $validated, 200, $response, $durationMs, $apiKey);
|
||||
|
||||
return response()->json($response);
|
||||
} catch (\Throwable $e) {
|
||||
$durationMs = (int) ((microtime(true) - $startTime) * 1000);
|
||||
|
||||
$this->logToolCall($apiKey, $validated, null, $durationMs, false, $e->getMessage());
|
||||
|
||||
// Dispatch webhooks (even on failure)
|
||||
$this->dispatchWebhook($apiKey, $validated, false, $durationMs, $e->getMessage());
|
||||
|
||||
$response = [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
'server' => $validated['server'],
|
||||
'tool' => $validated['tool'],
|
||||
];
|
||||
|
||||
// Log full request for debugging/replay
|
||||
$this->logApiRequest($request, $validated, 500, $response, $durationMs, $apiKey, $e->getMessage());
|
||||
|
||||
return response()->json($response, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a resource from an MCP server.
|
||||
*
|
||||
* GET /api/v1/mcp/resources/{uri}
|
||||
*
|
||||
* NOTE: Resource reading is not yet implemented. Returns 501 Not Implemented.
|
||||
*/
|
||||
public function resource(Request $request, string $uri): JsonResponse
|
||||
{
|
||||
// Parse URI format: server://resource/path
|
||||
if (! preg_match('/^([a-z0-9-]+):\/\/(.+)$/', $uri, $matches)) {
|
||||
return response()->json(['error' => 'Invalid resource URI format'], 400);
|
||||
}
|
||||
|
||||
$serverId = $matches[1];
|
||||
|
||||
$server = $this->loadServerFull($serverId);
|
||||
if (! $server) {
|
||||
return response()->json(['error' => 'Server not found'], 404);
|
||||
}
|
||||
|
||||
// Resource reading not yet implemented
|
||||
return response()->json([
|
||||
'error' => 'not_implemented',
|
||||
'message' => 'MCP resource reading is not yet implemented. Use tool calls instead.',
|
||||
'uri' => $uri,
|
||||
], 501);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute tool via artisan MCP server command.
|
||||
*/
|
||||
protected function executeToolViaArtisan(string $server, string $tool, array $arguments): mixed
|
||||
{
|
||||
$commandMap = config('api.mcp.server_commands', []);
|
||||
|
||||
$command = $commandMap[$server] ?? null;
|
||||
if (! $command) {
|
||||
throw new \RuntimeException("Unknown server: {$server}");
|
||||
}
|
||||
|
||||
// Build MCP request
|
||||
$mcpRequest = [
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => uniqid(),
|
||||
'method' => 'tools/call',
|
||||
'params' => [
|
||||
'name' => $tool,
|
||||
'arguments' => $arguments,
|
||||
],
|
||||
];
|
||||
|
||||
// Execute via process
|
||||
$process = proc_open(
|
||||
['php', 'artisan', $command],
|
||||
[
|
||||
0 => ['pipe', 'r'],
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
],
|
||||
$pipes,
|
||||
base_path()
|
||||
);
|
||||
|
||||
if (! is_resource($process)) {
|
||||
throw new \RuntimeException('Failed to start MCP server process');
|
||||
}
|
||||
|
||||
fwrite($pipes[0], json_encode($mcpRequest)."\n");
|
||||
fclose($pipes[0]);
|
||||
|
||||
$output = stream_get_contents($pipes[1]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
|
||||
proc_close($process);
|
||||
|
||||
$response = json_decode($output, true);
|
||||
|
||||
if (isset($response['error'])) {
|
||||
throw new \RuntimeException($response['error']['message'] ?? 'Tool execution failed');
|
||||
}
|
||||
|
||||
return $response['result'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log full API request for debugging and replay.
|
||||
*/
|
||||
protected function logApiRequest(
|
||||
Request $request,
|
||||
array $validated,
|
||||
int $status,
|
||||
array $response,
|
||||
int $durationMs,
|
||||
?ApiKey $apiKey,
|
||||
?string $error = null
|
||||
): void {
|
||||
try {
|
||||
McpApiRequest::log(
|
||||
method: $request->method(),
|
||||
path: '/tools/call',
|
||||
requestBody: $validated,
|
||||
responseStatus: $status,
|
||||
responseBody: $response,
|
||||
durationMs: $durationMs,
|
||||
workspaceId: $apiKey?->workspace_id,
|
||||
apiKeyId: $apiKey?->id,
|
||||
serverId: $validated['server'],
|
||||
toolName: $validated['tool'],
|
||||
errorMessage: $error,
|
||||
ipAddress: $request->ip(),
|
||||
headers: $request->headers->all()
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
// Don't let logging failures affect API response
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch webhook for tool execution.
|
||||
*/
|
||||
protected function dispatchWebhook(
|
||||
?ApiKey $apiKey,
|
||||
array $request,
|
||||
bool $success,
|
||||
int $durationMs,
|
||||
?string $error = null
|
||||
): void {
|
||||
if (! $apiKey?->workspace_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$dispatcher = new McpWebhookDispatcher;
|
||||
$dispatcher->dispatchToolExecuted(
|
||||
workspaceId: $apiKey->workspace_id,
|
||||
serverId: $request['server'],
|
||||
toolName: $request['tool'],
|
||||
arguments: $request['arguments'] ?? [],
|
||||
success: $success,
|
||||
durationMs: $durationMs,
|
||||
errorMessage: $error
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
// Don't let webhook failures affect API response
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log tool call for analytics.
|
||||
*/
|
||||
protected function logToolCall(
|
||||
?ApiKey $apiKey,
|
||||
array $request,
|
||||
mixed $result,
|
||||
int $durationMs,
|
||||
bool $success,
|
||||
?string $error = null
|
||||
): void {
|
||||
McpToolCall::log(
|
||||
serverId: $request['server'],
|
||||
toolName: $request['tool'],
|
||||
params: $request['arguments'] ?? [],
|
||||
success: $success,
|
||||
durationMs: $durationMs,
|
||||
errorMessage: $error,
|
||||
workspaceId: $apiKey?->workspace_id
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate tool arguments against the tool's input schema.
|
||||
*
|
||||
* @return array<string> Validation errors (empty if valid)
|
||||
*/
|
||||
protected function validateToolArguments(array $toolDef, array $arguments): array
|
||||
{
|
||||
$inputSchema = $toolDef['inputSchema'] ?? null;
|
||||
|
||||
// No schema = no validation
|
||||
if (! $inputSchema || ! is_array($inputSchema)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
$properties = $inputSchema['properties'] ?? [];
|
||||
$required = $inputSchema['required'] ?? [];
|
||||
|
||||
// Check required properties
|
||||
foreach ($required as $requiredProp) {
|
||||
if (! array_key_exists($requiredProp, $arguments)) {
|
||||
$errors[] = "Missing required argument: {$requiredProp}";
|
||||
}
|
||||
}
|
||||
|
||||
// Type validation for provided arguments
|
||||
foreach ($arguments as $key => $value) {
|
||||
// Check if argument is defined in schema
|
||||
if (! isset($properties[$key])) {
|
||||
// Allow extra properties unless additionalProperties is false
|
||||
if (isset($inputSchema['additionalProperties']) && $inputSchema['additionalProperties'] === false) {
|
||||
$errors[] = "Unknown argument: {$key}";
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$propSchema = $properties[$key];
|
||||
$expectedType = $propSchema['type'] ?? null;
|
||||
|
||||
if ($expectedType && ! $this->validateType($value, $expectedType)) {
|
||||
$errors[] = "Argument '{$key}' must be of type {$expectedType}";
|
||||
}
|
||||
|
||||
// Validate enum values
|
||||
if (isset($propSchema['enum']) && ! in_array($value, $propSchema['enum'], true)) {
|
||||
$allowedValues = implode(', ', $propSchema['enum']);
|
||||
$errors[] = "Argument '{$key}' must be one of: {$allowedValues}";
|
||||
}
|
||||
|
||||
// Validate string constraints
|
||||
if ($expectedType === 'string' && is_string($value)) {
|
||||
if (isset($propSchema['minLength']) && strlen($value) < $propSchema['minLength']) {
|
||||
$errors[] = "Argument '{$key}' must be at least {$propSchema['minLength']} characters";
|
||||
}
|
||||
if (isset($propSchema['maxLength']) && strlen($value) > $propSchema['maxLength']) {
|
||||
$errors[] = "Argument '{$key}' must be at most {$propSchema['maxLength']} characters";
|
||||
}
|
||||
}
|
||||
|
||||
// Validate numeric constraints
|
||||
if (in_array($expectedType, ['integer', 'number']) && is_numeric($value)) {
|
||||
if (isset($propSchema['minimum']) && $value < $propSchema['minimum']) {
|
||||
$errors[] = "Argument '{$key}' must be at least {$propSchema['minimum']}";
|
||||
}
|
||||
if (isset($propSchema['maximum']) && $value > $propSchema['maximum']) {
|
||||
$errors[] = "Argument '{$key}' must be at most {$propSchema['maximum']}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a value against a JSON Schema type.
|
||||
*/
|
||||
protected function validateType(mixed $value, string $type): bool
|
||||
{
|
||||
return match ($type) {
|
||||
'string' => is_string($value),
|
||||
'integer' => is_int($value) || (is_numeric($value) && floor((float) $value) == $value),
|
||||
'number' => is_numeric($value),
|
||||
'boolean' => is_bool($value),
|
||||
'array' => is_array($value) && array_is_list($value),
|
||||
'object' => is_array($value) && ! array_is_list($value),
|
||||
'null' => is_null($value),
|
||||
default => true, // Unknown types pass validation
|
||||
};
|
||||
}
|
||||
|
||||
// Registry loading methods (shared with McpRegistryController)
|
||||
|
||||
protected function loadRegistry(): array
|
||||
{
|
||||
return Cache::remember('mcp:registry', 600, function () {
|
||||
$path = resource_path('mcp/registry.yaml');
|
||||
|
||||
return file_exists($path) ? Yaml::parseFile($path) : ['servers' => []];
|
||||
});
|
||||
}
|
||||
|
||||
protected function loadServerFull(string $id): ?array
|
||||
{
|
||||
return Cache::remember("mcp:server:{$id}", 600, function () use ($id) {
|
||||
$path = resource_path("mcp/servers/{$id}.yaml");
|
||||
|
||||
return file_exists($path) ? Yaml::parseFile($path) : null;
|
||||
});
|
||||
}
|
||||
|
||||
protected function loadServerSummary(string $id): ?array
|
||||
{
|
||||
$server = $this->loadServerFull($id);
|
||||
if (! $server) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $server['id'],
|
||||
'name' => $server['name'],
|
||||
'tagline' => $server['tagline'] ?? '',
|
||||
'status' => $server['status'] ?? 'available',
|
||||
'tool_count' => count($server['tools'] ?? []),
|
||||
'resource_count' => count($server['resources'] ?? []),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Mcp\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Exception thrown when the circuit breaker is open and no fallback is provided.
|
||||
*
|
||||
* This indicates the target service is temporarily unavailable due to repeated failures.
|
||||
*/
|
||||
class CircuitOpenException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $service,
|
||||
string $message = '',
|
||||
) {
|
||||
$message = $message ?: sprintf(
|
||||
"Service '%s' is temporarily unavailable. Please try again later.",
|
||||
$service
|
||||
);
|
||||
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,207 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Mcp\Prompts;
|
||||
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Prompt;
|
||||
use Laravel\Mcp\Server\Prompts\Argument;
|
||||
|
||||
/**
|
||||
* MCP prompt for analysing biolink performance.
|
||||
*
|
||||
* Guides through retrieving and interpreting analytics data,
|
||||
* identifying trends, and suggesting improvements.
|
||||
*
|
||||
* Part of TASK-011 Phase 12: MCP Tools Expansion for BioHost (AC53).
|
||||
*/
|
||||
class AnalysePerformancePrompt extends Prompt
|
||||
{
|
||||
protected string $name = 'analyse_performance';
|
||||
|
||||
protected string $title = 'Analyse Bio Link Performance';
|
||||
|
||||
protected string $description = 'Analyse biolink analytics and provide actionable insights for improvement';
|
||||
|
||||
/**
|
||||
* @return array<int, Argument>
|
||||
*/
|
||||
public function arguments(): array
|
||||
{
|
||||
return [
|
||||
new Argument(
|
||||
name: 'biolink_id',
|
||||
description: 'The ID of the biolink to analyse',
|
||||
required: true
|
||||
),
|
||||
new Argument(
|
||||
name: 'period',
|
||||
description: 'Analysis period: 7d, 30d, 90d (default: 30d)',
|
||||
required: false
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(): Response
|
||||
{
|
||||
return Response::text(<<<'PROMPT'
|
||||
# Analyse Bio Link Performance
|
||||
|
||||
This workflow helps you analyse a biolink's performance and provide actionable recommendations.
|
||||
|
||||
## Step 1: Gather Analytics Data
|
||||
|
||||
Fetch detailed analytics:
|
||||
```json
|
||||
{
|
||||
"action": "get_analytics_detailed",
|
||||
"biolink_id": <biolink_id>,
|
||||
"period": "30d",
|
||||
"include": ["geo", "devices", "referrers", "utm", "blocks"]
|
||||
}
|
||||
```
|
||||
|
||||
Also get basic biolink info:
|
||||
```json
|
||||
{
|
||||
"action": "get",
|
||||
"biolink_id": <biolink_id>
|
||||
}
|
||||
```
|
||||
|
||||
## Step 2: Analyse the Data
|
||||
|
||||
Review these key metrics:
|
||||
|
||||
### Traffic Overview
|
||||
- **Total clicks**: Overall engagement
|
||||
- **Unique clicks**: Individual visitors
|
||||
- **Click rate trend**: Is traffic growing or declining?
|
||||
|
||||
### Geographic Insights
|
||||
Look at the `geo.countries` data:
|
||||
- Where is traffic coming from?
|
||||
- Are target markets represented?
|
||||
- Any unexpected sources?
|
||||
|
||||
### Device Breakdown
|
||||
Examine `devices` data:
|
||||
- Mobile vs desktop ratio
|
||||
- Browser distribution
|
||||
- Operating systems
|
||||
|
||||
**Optimisation tip:** If mobile traffic is high (>60%), ensure blocks are mobile-friendly.
|
||||
|
||||
### Traffic Sources
|
||||
Analyse `referrers`:
|
||||
- Direct traffic (typed URL, QR codes)
|
||||
- Social media sources
|
||||
- Search engines
|
||||
- Other websites
|
||||
|
||||
### UTM Campaign Performance
|
||||
If using UTM tracking, review `utm`:
|
||||
- Which campaigns drive traffic?
|
||||
- Which sources convert best?
|
||||
|
||||
### Block Performance
|
||||
The `blocks` data shows:
|
||||
- Which links get the most clicks
|
||||
- Click-through rate per block
|
||||
- Underperforming content
|
||||
|
||||
## Step 3: Identify Issues
|
||||
|
||||
Common issues to look for:
|
||||
|
||||
### Low Click-Through Rate
|
||||
If total clicks are high but block clicks are low:
|
||||
- Consider reordering blocks (most important first)
|
||||
- Review link text clarity
|
||||
- Check if call-to-action is compelling
|
||||
|
||||
### High Bounce Rate
|
||||
If unique clicks are close to total clicks with low block engagement:
|
||||
- Page may not match visitor expectations
|
||||
- Loading issues on certain devices
|
||||
- Content not relevant to traffic source
|
||||
|
||||
### Geographic Mismatch
|
||||
If traffic is from unexpected regions:
|
||||
- Review where links are being shared
|
||||
- Consider language/localisation
|
||||
- Check for bot traffic
|
||||
|
||||
### Mobile Performance Issues
|
||||
If mobile traffic shows different patterns:
|
||||
- Test page on mobile devices
|
||||
- Ensure buttons are tap-friendly
|
||||
- Check image loading
|
||||
|
||||
## Step 4: Generate Recommendations
|
||||
|
||||
Based on analysis, suggest:
|
||||
|
||||
### Quick Wins
|
||||
- Reorder blocks by popularity
|
||||
- Update underperforming link text
|
||||
- Add missing social platforms
|
||||
|
||||
### Medium-Term Improvements
|
||||
- Create targeted content for top traffic sources
|
||||
- Implement A/B testing for key links
|
||||
- Add tracking for better attribution
|
||||
|
||||
### Strategic Changes
|
||||
- Adjust marketing spend based on source performance
|
||||
- Consider custom domains for branding
|
||||
- Set up notification alerts for engagement milestones
|
||||
|
||||
## Step 5: Present Findings
|
||||
|
||||
Summarise for the user:
|
||||
|
||||
```markdown
|
||||
## Performance Summary for [Biolink Name]
|
||||
|
||||
### Key Metrics (Last 30 Days)
|
||||
- Total Clicks: X,XXX
|
||||
- Unique Visitors: X,XXX
|
||||
- Top Performing Block: [Name] (XX% of clicks)
|
||||
|
||||
### Traffic Sources
|
||||
1. [Source 1] - XX%
|
||||
2. [Source 2] - XX%
|
||||
3. [Source 3] - XX%
|
||||
|
||||
### Geographic Distribution
|
||||
- [Country 1] - XX%
|
||||
- [Country 2] - XX%
|
||||
- [Country 3] - XX%
|
||||
|
||||
### Recommendations
|
||||
1. [High Priority Action]
|
||||
2. [Medium Priority Action]
|
||||
3. [Low Priority Action]
|
||||
|
||||
### Next Steps
|
||||
- [Specific action item]
|
||||
- Schedule follow-up analysis in [timeframe]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Analytics Periods:**
|
||||
- `7d` - Last 7 days (quick check)
|
||||
- `30d` - Last 30 days (standard analysis)
|
||||
- `90d` - Last 90 days (trend analysis)
|
||||
|
||||
**Note:** Analytics retention may be limited based on the workspace's subscription tier.
|
||||
|
||||
**Pro Tips:**
|
||||
- Compare week-over-week for seasonal patterns
|
||||
- Cross-reference with marketing calendar
|
||||
- Export submission data for lead quality analysis
|
||||
PROMPT
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Mcp\Prompts;
|
||||
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Prompt;
|
||||
use Laravel\Mcp\Server\Prompts\Argument;
|
||||
|
||||
/**
|
||||
* MCP prompt for configuring biolink notifications.
|
||||
*
|
||||
* Guides through setting up notification handlers for various events
|
||||
* like clicks, form submissions, and payments.
|
||||
*
|
||||
* Part of TASK-011 Phase 12: MCP Tools Expansion for BioHost (AC53).
|
||||
*/
|
||||
class ConfigureNotificationsPrompt extends Prompt
|
||||
{
|
||||
protected string $name = 'configure_notifications';
|
||||
|
||||
protected string $title = 'Configure Notifications';
|
||||
|
||||
protected string $description = 'Set up notification handlers for biolink events (clicks, form submissions, etc.)';
|
||||
|
||||
/**
|
||||
* @return array<int, Argument>
|
||||
*/
|
||||
public function arguments(): array
|
||||
{
|
||||
return [
|
||||
new Argument(
|
||||
name: 'biolink_id',
|
||||
description: 'The ID of the biolink to configure notifications for',
|
||||
required: true
|
||||
),
|
||||
new Argument(
|
||||
name: 'notification_type',
|
||||
description: 'Type of notification: webhook, email, slack, discord, or telegram',
|
||||
required: false
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(): Response
|
||||
{
|
||||
return Response::text(<<<'PROMPT'
|
||||
# Configure Biolink Notifications
|
||||
|
||||
Set up real-time notifications when visitors interact with your biolink page.
|
||||
|
||||
## Available Event Types
|
||||
|
||||
| Event | Description |
|
||||
|-------|-------------|
|
||||
| `click` | Page view or link click |
|
||||
| `block_click` | Specific block clicked |
|
||||
| `form_submit` | Email/phone/contact form submission |
|
||||
| `payment` | Payment received (if applicable) |
|
||||
|
||||
## Available Handler Types
|
||||
|
||||
### 1. Webhook (Custom Integration)
|
||||
|
||||
Send HTTP POST requests to your own endpoint:
|
||||
```json
|
||||
{
|
||||
"action": "create_notification_handler",
|
||||
"biolink_id": <biolink_id>,
|
||||
"name": "My Webhook",
|
||||
"type": "webhook",
|
||||
"events": ["form_submit", "payment"],
|
||||
"settings": {
|
||||
"url": "https://your-server.com/webhook",
|
||||
"secret": "optional-hmac-secret"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Webhook payload includes:
|
||||
- Event type and timestamp
|
||||
- Biolink and block details
|
||||
- Visitor data (country, device type)
|
||||
- Form data (for submissions)
|
||||
- HMAC signature header if secret is set
|
||||
|
||||
### 2. Email Notifications
|
||||
|
||||
Send email alerts:
|
||||
```json
|
||||
{
|
||||
"action": "create_notification_handler",
|
||||
"biolink_id": <biolink_id>,
|
||||
"name": "Email Alerts",
|
||||
"type": "email",
|
||||
"events": ["form_submit"],
|
||||
"settings": {
|
||||
"recipients": ["alerts@example.com", "team@example.com"],
|
||||
"subject_prefix": "[BioLink]"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Slack Integration
|
||||
|
||||
Post to a Slack channel:
|
||||
```json
|
||||
{
|
||||
"action": "create_notification_handler",
|
||||
"biolink_id": <biolink_id>,
|
||||
"name": "Slack Notifications",
|
||||
"type": "slack",
|
||||
"events": ["form_submit", "click"],
|
||||
"settings": {
|
||||
"webhook_url": "https://hooks.slack.com/services/T.../B.../xxx",
|
||||
"channel": "#leads",
|
||||
"username": "BioLink Bot"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To get a Slack webhook URL:
|
||||
1. Go to https://api.slack.com/apps
|
||||
2. Create or select an app
|
||||
3. Enable "Incoming Webhooks"
|
||||
4. Add a webhook to your workspace
|
||||
|
||||
### 4. Discord Integration
|
||||
|
||||
Post to a Discord channel:
|
||||
```json
|
||||
{
|
||||
"action": "create_notification_handler",
|
||||
"biolink_id": <biolink_id>,
|
||||
"name": "Discord Notifications",
|
||||
"type": "discord",
|
||||
"events": ["form_submit"],
|
||||
"settings": {
|
||||
"webhook_url": "https://discord.com/api/webhooks/xxx/yyy",
|
||||
"username": "BioLink"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To get a Discord webhook URL:
|
||||
1. Open channel settings
|
||||
2. Go to Integrations > Webhooks
|
||||
3. Create a new webhook
|
||||
|
||||
### 5. Telegram Integration
|
||||
|
||||
Send messages to a Telegram chat:
|
||||
```json
|
||||
{
|
||||
"action": "create_notification_handler",
|
||||
"biolink_id": <biolink_id>,
|
||||
"name": "Telegram Alerts",
|
||||
"type": "telegram",
|
||||
"events": ["form_submit"],
|
||||
"settings": {
|
||||
"bot_token": "123456:ABC-DEF...",
|
||||
"chat_id": "-1001234567890"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To set up Telegram:
|
||||
1. Message @BotFather to create a bot
|
||||
2. Get the bot token
|
||||
3. Add the bot to your group/channel
|
||||
4. Get the chat ID (use @userinfobot or API)
|
||||
|
||||
## Managing Handlers
|
||||
|
||||
### List Existing Handlers
|
||||
```json
|
||||
{
|
||||
"action": "list_notification_handlers",
|
||||
"biolink_id": <biolink_id>
|
||||
}
|
||||
```
|
||||
|
||||
### Update a Handler
|
||||
```json
|
||||
{
|
||||
"action": "update_notification_handler",
|
||||
"handler_id": <handler_id>,
|
||||
"events": ["form_submit"],
|
||||
"is_enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
### Test a Handler
|
||||
```json
|
||||
{
|
||||
"action": "test_notification_handler",
|
||||
"handler_id": <handler_id>
|
||||
}
|
||||
```
|
||||
|
||||
### Disable or Delete
|
||||
```json
|
||||
{
|
||||
"action": "update_notification_handler",
|
||||
"handler_id": <handler_id>,
|
||||
"is_enabled": false
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "delete_notification_handler",
|
||||
"handler_id": <handler_id>
|
||||
}
|
||||
```
|
||||
|
||||
## Auto-Disable Behaviour
|
||||
|
||||
Handlers are automatically disabled after 5 consecutive failures. To re-enable:
|
||||
```json
|
||||
{
|
||||
"action": "update_notification_handler",
|
||||
"handler_id": <handler_id>,
|
||||
"is_enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
This resets the failure counter.
|
||||
|
||||
---
|
||||
|
||||
**Tips:**
|
||||
- Use form_submit events for lead generation alerts
|
||||
- Combine multiple handlers for redundancy
|
||||
- Test handlers after creation to verify configuration
|
||||
- Monitor trigger_count and consecutive_failures in list output
|
||||
PROMPT
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,205 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Mcp\Prompts;
|
||||
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Prompt;
|
||||
use Laravel\Mcp\Server\Prompts\Argument;
|
||||
|
||||
/**
|
||||
* MCP prompt for setting up a QR code campaign.
|
||||
*
|
||||
* Guides through creating a short link with QR code and tracking pixel
|
||||
* for print materials, packaging, or offline-to-online campaigns.
|
||||
*
|
||||
* Part of TASK-011 Phase 12: MCP Tools Expansion for BioHost (AC53).
|
||||
*/
|
||||
class SetupQrCampaignPrompt extends Prompt
|
||||
{
|
||||
protected string $name = 'setup_qr_campaign';
|
||||
|
||||
protected string $title = 'Set Up QR Code Campaign';
|
||||
|
||||
protected string $description = 'Create a short link with QR code and tracking for print materials or offline campaigns';
|
||||
|
||||
/**
|
||||
* @return array<int, Argument>
|
||||
*/
|
||||
public function arguments(): array
|
||||
{
|
||||
return [
|
||||
new Argument(
|
||||
name: 'destination_url',
|
||||
description: 'The URL where the QR code should redirect to',
|
||||
required: true
|
||||
),
|
||||
new Argument(
|
||||
name: 'campaign_name',
|
||||
description: 'A name for this campaign (e.g., "Summer Flyer 2024")',
|
||||
required: true
|
||||
),
|
||||
new Argument(
|
||||
name: 'tracking_platform',
|
||||
description: 'Analytics platform to use (google_analytics, facebook, etc.)',
|
||||
required: false
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(): Response
|
||||
{
|
||||
return Response::text(<<<'PROMPT'
|
||||
# Set Up a QR Code Campaign
|
||||
|
||||
This workflow creates a trackable short link with a QR code for print materials, packaging, or any offline-to-online campaign.
|
||||
|
||||
## Step 1: Gather Campaign Details
|
||||
|
||||
Ask the user for:
|
||||
- **Destination URL**: Where should the QR code redirect?
|
||||
- **Campaign name**: For organisation (e.g., "Spring 2024 Flyers")
|
||||
- **UTM parameters**: Optional tracking parameters
|
||||
- **QR code style**: Colour preferences, size requirements
|
||||
|
||||
## Step 2: Create a Short Link
|
||||
|
||||
Create a redirect-type biolink:
|
||||
```json
|
||||
{
|
||||
"action": "create",
|
||||
"user_id": <user_id>,
|
||||
"url": "<short-slug>",
|
||||
"type": "link",
|
||||
"location_url": "<destination-url>?utm_source=qr&utm_campaign=<campaign-name>"
|
||||
}
|
||||
```
|
||||
|
||||
**Tip:** Include UTM parameters in the destination URL for better attribution in Google Analytics.
|
||||
|
||||
## Step 3: Set Up Tracking Pixel (Optional)
|
||||
|
||||
If the user wants conversion tracking, create a pixel:
|
||||
```json
|
||||
{
|
||||
"action": "create_pixel",
|
||||
"user_id": <user_id>,
|
||||
"type": "google_analytics",
|
||||
"pixel_id": "G-XXXXXXXXXX",
|
||||
"name": "<campaign-name> Tracking"
|
||||
}
|
||||
```
|
||||
|
||||
Available pixel types:
|
||||
- `google_analytics` - GA4 measurement
|
||||
- `google_tag_manager` - GTM container
|
||||
- `facebook` - Meta Pixel
|
||||
- `tiktok` - TikTok Pixel
|
||||
- `linkedin` - LinkedIn Insight Tag
|
||||
- `twitter` - Twitter Pixel
|
||||
|
||||
Attach the pixel to the link:
|
||||
```json
|
||||
{
|
||||
"action": "attach_pixel",
|
||||
"biolink_id": <biolink_id>,
|
||||
"pixel_id": <pixel_id>
|
||||
}
|
||||
```
|
||||
|
||||
## Step 4: Organise in a Project
|
||||
|
||||
Create or use a campaign project:
|
||||
```json
|
||||
{
|
||||
"action": "create_project",
|
||||
"user_id": <user_id>,
|
||||
"name": "QR Campaigns 2024",
|
||||
"color": "#6366f1"
|
||||
}
|
||||
```
|
||||
|
||||
Move the link to the project:
|
||||
```json
|
||||
{
|
||||
"action": "move_to_project",
|
||||
"biolink_id": <biolink_id>,
|
||||
"project_id": <project_id>
|
||||
}
|
||||
```
|
||||
|
||||
## Step 5: Generate the QR Code
|
||||
|
||||
Generate with default settings (black on white, 400px):
|
||||
```json
|
||||
{
|
||||
"action": "generate_qr",
|
||||
"biolink_id": <biolink_id>
|
||||
}
|
||||
```
|
||||
|
||||
Generate with custom styling:
|
||||
```json
|
||||
{
|
||||
"action": "generate_qr",
|
||||
"biolink_id": <biolink_id>,
|
||||
"size": 600,
|
||||
"foreground_colour": "#1a1a1a",
|
||||
"background_colour": "#ffffff",
|
||||
"module_style": "rounded",
|
||||
"ecc_level": "H"
|
||||
}
|
||||
```
|
||||
|
||||
**QR Code Options:**
|
||||
- `size`: 100-1000 pixels (default: 400)
|
||||
- `format`: "png" or "svg"
|
||||
- `foreground_colour`: Hex colour for QR modules (default: #000000)
|
||||
- `background_colour`: Hex colour for background (default: #ffffff)
|
||||
- `module_style`: "square", "rounded", or "dots"
|
||||
- `ecc_level`: Error correction - "L", "M", "Q", or "H" (higher = more resilient but denser)
|
||||
|
||||
The response includes a `data_uri` that can be used directly in HTML or saved as an image.
|
||||
|
||||
## Step 6: Set Up Notifications (Optional)
|
||||
|
||||
Get notified when someone scans the QR code:
|
||||
```json
|
||||
{
|
||||
"action": "create_notification_handler",
|
||||
"biolink_id": <biolink_id>,
|
||||
"name": "<campaign-name> Alerts",
|
||||
"type": "slack",
|
||||
"events": ["click"],
|
||||
"settings": {
|
||||
"webhook_url": "https://hooks.slack.com/services/..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 7: Review and Deliver
|
||||
|
||||
Get the final link details:
|
||||
```json
|
||||
{
|
||||
"action": "get",
|
||||
"biolink_id": <biolink_id>
|
||||
}
|
||||
```
|
||||
|
||||
Provide the user with:
|
||||
1. The short URL for reference
|
||||
2. The QR code image (data URI or downloadable)
|
||||
3. Instructions for the print designer
|
||||
|
||||
---
|
||||
|
||||
**Best Practices:**
|
||||
- Use error correction level "H" for QR codes on curved surfaces or small prints
|
||||
- Keep foreground/background contrast high for reliable scanning
|
||||
- Test the QR code on multiple devices before printing
|
||||
- Include the short URL as text near the QR code as a fallback
|
||||
- Use different short links for each print run to track effectiveness
|
||||
PROMPT
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Mcp\Servers;
|
||||
|
||||
use Core\Mod\Web\Mcp\BioResource;
|
||||
use Core\Mod\Mcp\Prompts\AnalysePerformancePrompt;
|
||||
use Core\Mod\Mcp\Prompts\ConfigureNotificationsPrompt;
|
||||
use Core\Mod\Mcp\Prompts\CreateBioPagePrompt;
|
||||
use Core\Mod\Mcp\Prompts\SetupQrCampaignPrompt;
|
||||
use Core\Mod\Mcp\Resources\AppConfig;
|
||||
use Core\Mod\Mcp\Resources\ContentResource;
|
||||
use Core\Mod\Mcp\Resources\DatabaseSchema;
|
||||
use Core\Mod\Mcp\Tools\Commerce\CreateCoupon;
|
||||
use Core\Mod\Mcp\Tools\Commerce\GetBillingStatus;
|
||||
use Core\Mod\Mcp\Tools\Commerce\ListInvoices;
|
||||
use Core\Mod\Mcp\Tools\Commerce\UpgradePlan;
|
||||
use Core\Mod\Mcp\Tools\ContentTools;
|
||||
use Core\Mod\Mcp\Tools\GetStats;
|
||||
use Core\Mod\Mcp\Tools\ListRoutes;
|
||||
use Core\Mod\Mcp\Tools\ListSites;
|
||||
use Core\Mod\Mcp\Tools\ListTables;
|
||||
use Core\Mod\Mcp\Tools\QueryDatabase;
|
||||
use Laravel\Mcp\Server;
|
||||
|
||||
class HostHub extends Server
|
||||
{
|
||||
protected string $name = 'Host Hub';
|
||||
|
||||
protected string $version = '1.0.0';
|
||||
|
||||
protected string $instructions = <<<'MARKDOWN'
|
||||
Host Hub MCP Server provides tools for querying and inspecting the Host UK hosting platform.
|
||||
|
||||
## System Tools
|
||||
- list-sites: List all 6 Host UK services
|
||||
- get-stats: Get current system statistics
|
||||
- list-routes: List all web routes
|
||||
- query-database: Execute read-only SQL SELECT queries
|
||||
- list-tables: List database tables
|
||||
|
||||
## Commerce Tools
|
||||
- get-billing-status: Get subscription and billing status for a workspace
|
||||
- list-invoices: List invoices for a workspace
|
||||
- create-coupon: Create a new discount coupon
|
||||
- upgrade-plan: Preview or execute a plan change
|
||||
|
||||
## Content Tools
|
||||
Manage native CMS content (blog posts, pages):
|
||||
- content_tools action=list: List content items for a workspace
|
||||
- content_tools action=read: Read full content by slug or ID
|
||||
- content_tools action=create: Create new content (draft, published, scheduled)
|
||||
- content_tools action=update: Update existing content
|
||||
- content_tools action=delete: Soft delete content
|
||||
- content_tools action=taxonomies: List categories and tags
|
||||
|
||||
## BioLink Tools (BioHost)
|
||||
Manage bio link pages, domains, pixels, themes, and notifications:
|
||||
|
||||
### Core Operations (biolink_tools)
|
||||
- biolink_tools action=list: List biolinks for a user
|
||||
- biolink_tools action=get: Get biolink details with blocks
|
||||
- biolink_tools action=create: Create new biolink page
|
||||
- biolink_tools action=update: Update biolink settings
|
||||
- biolink_tools action=delete: Delete a biolink
|
||||
- biolink_tools action=add_block: Add a block to biolink
|
||||
- biolink_tools action=update_block: Update block settings
|
||||
- biolink_tools action=delete_block: Remove a block
|
||||
|
||||
### Analytics (analytics_tools)
|
||||
- analytics_tools action=stats: Get click statistics
|
||||
- analytics_tools action=detailed: Get geo, device, referrer, UTM breakdown
|
||||
|
||||
### Domains (domain_tools)
|
||||
- domain_tools action=list: List custom domains
|
||||
- domain_tools action=add: Add domain with verification instructions
|
||||
- domain_tools action=verify: Trigger DNS verification
|
||||
- domain_tools action=delete: Remove a domain
|
||||
|
||||
### Projects (project_tools)
|
||||
- project_tools action=list: List projects
|
||||
- project_tools action=create: Create a project
|
||||
- project_tools action=update: Update a project
|
||||
- project_tools action=delete: Delete a project
|
||||
- project_tools action=move_biolink: Move biolink to project
|
||||
|
||||
### Tracking Pixels (pixel_tools)
|
||||
- pixel_tools action=list: List tracking pixels
|
||||
- pixel_tools action=create: Create pixel (Facebook, GA4, GTM, etc.)
|
||||
- pixel_tools action=update: Update pixel
|
||||
- pixel_tools action=delete: Delete pixel
|
||||
- pixel_tools action=attach: Attach pixel to biolink
|
||||
- pixel_tools action=detach: Remove pixel from biolink
|
||||
|
||||
### QR Codes (qr_tools)
|
||||
- qr_tools action=generate: Generate QR code with custom styling
|
||||
|
||||
### Themes (theme_tools)
|
||||
- theme_tools action=list: List available themes
|
||||
- theme_tools action=apply: Apply theme to biolink
|
||||
- theme_tools action=create_custom: Create custom theme
|
||||
- theme_tools action=delete: Delete custom theme
|
||||
- theme_tools action=search: Search themes
|
||||
- theme_tools action=toggle_favourite: Toggle favourite theme
|
||||
|
||||
### Social Proof (TrustHost - trust_tools)
|
||||
Manage social proof widgets and campaigns:
|
||||
- trust_campaign_tools action=list: List campaigns
|
||||
- trust_campaign_tools action=get: Get campaign details
|
||||
- trust_notification_tools action=list: List widgets for campaign
|
||||
- trust_notification_tools action=get: Get widget details
|
||||
- trust_notification_tools action=create: Create new widget
|
||||
- trust_notification_tools action=types: List available widget types
|
||||
- trust_analytics_tools action=stats: Get performance statistics
|
||||
|
||||
## Available Prompts
|
||||
- create_biolink_page: Step-by-step biolink page creation
|
||||
- setup_qr_campaign: Create QR code campaign with tracking
|
||||
- configure_notifications: Set up notification handlers
|
||||
- analyse_performance: Analyse biolink performance with recommendations
|
||||
|
||||
## Available Resources
|
||||
- config://app: Application configuration
|
||||
- schema://database: Full database schema
|
||||
- content://{workspace}/{slug}: Content item as markdown
|
||||
- biolink://{workspace}/{slug}: Biolink page as markdown
|
||||
MARKDOWN;
|
||||
|
||||
protected array $tools = [
|
||||
ListSites::class,
|
||||
GetStats::class,
|
||||
ListRoutes::class,
|
||||
QueryDatabase::class,
|
||||
ListTables::class,
|
||||
// Commerce tools
|
||||
GetBillingStatus::class,
|
||||
ListInvoices::class,
|
||||
CreateCoupon::class,
|
||||
UpgradePlan::class,
|
||||
// Content tools
|
||||
ContentTools::class,
|
||||
// BioHost tools
|
||||
\Core\Mod\Web\Mcp\Tools\BioLinkTools::class,
|
||||
\Core\Mod\Web\Mcp\Tools\AnalyticsTools::class,
|
||||
\Core\Mod\Web\Mcp\Tools\DomainTools::class,
|
||||
\Core\Mod\Web\Mcp\Tools\ProjectTools::class,
|
||||
\Core\Mod\Web\Mcp\Tools\PixelTools::class,
|
||||
\Core\Mod\Web\Mcp\Tools\QrTools::class,
|
||||
\Core\Mod\Web\Mcp\Tools\ThemeTools::class,
|
||||
\Core\Mod\Web\Mcp\Tools\NotificationTools::class,
|
||||
\Core\Mod\Web\Mcp\Tools\SubmissionTools::class,
|
||||
\Core\Mod\Web\Mcp\Tools\TemplateTools::class,
|
||||
\Core\Mod\Web\Mcp\Tools\StaticPageTools::class,
|
||||
\Core\Mod\Web\Mcp\Tools\PwaTools::class,
|
||||
// TrustHost tools
|
||||
\Core\Mod\Trust\Mcp\Tools\CampaignTools::class,
|
||||
\Core\Mod\Trust\Mcp\Tools\NotificationTools::class,
|
||||
\Core\Mod\Trust\Mcp\Tools\AnalyticsTools::class,
|
||||
];
|
||||
|
||||
protected array $resources = [
|
||||
AppConfig::class,
|
||||
DatabaseSchema::class,
|
||||
ContentResource::class,
|
||||
BioResource::class,
|
||||
];
|
||||
|
||||
protected array $prompts = [
|
||||
CreateBioPagePrompt::class,
|
||||
SetupQrCampaignPrompt::class,
|
||||
ConfigureNotificationsPrompt::class,
|
||||
AnalysePerformancePrompt::class,
|
||||
];
|
||||
}
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Mcp\Servers;
|
||||
|
||||
use Core\Mod\Mcp\Tools\AnalyticsTools;
|
||||
use Core\Mod\Mcp\Tools\PushNotificationTools;
|
||||
use Laravel\Mcp\Server;
|
||||
|
||||
/**
|
||||
* Marketing MCP Server.
|
||||
*
|
||||
* Provides a unified interface for MCP agents to interact with
|
||||
* Host UK's marketing platform:
|
||||
* - BioHost (bio link pages)
|
||||
* - AnalyticsHost (website analytics)
|
||||
* - NotifyHost (push notifications)
|
||||
* - TrustHost (social proof widgets)
|
||||
*/
|
||||
class Marketing extends Server
|
||||
{
|
||||
protected string $name = 'Host UK Marketing';
|
||||
|
||||
protected string $version = '1.0.0';
|
||||
|
||||
protected string $instructions = <<<'MARKDOWN'
|
||||
Host UK Marketing MCP Server provides tools for managing the complete marketing platform.
|
||||
|
||||
## Available Tools
|
||||
|
||||
### BioLink Tools (BioHost)
|
||||
Manage bio link pages, domains, pixels, themes, and notifications:
|
||||
|
||||
#### Core Operations (biolink_tools)
|
||||
- `list` - List all bio links
|
||||
- `get` - Get bio link details with blocks
|
||||
- `create` - Create a new bio link page
|
||||
- `add_block` - Add a content block
|
||||
- `update_block` - Update block settings
|
||||
- `delete_block` - Remove a block
|
||||
|
||||
#### Analytics (analytics_tools)
|
||||
- `stats` - Get click statistics
|
||||
- `detailed` - Get detailed breakdown
|
||||
|
||||
#### Domains (domain_tools)
|
||||
- `list` - List custom domains
|
||||
- `add` - Add domain
|
||||
- `verify` - Verify DNS
|
||||
|
||||
#### Themes (theme_tools)
|
||||
- `list` - List themes
|
||||
- `apply` - Apply theme
|
||||
|
||||
#### Other Bio Tools
|
||||
- `qr_tools` - Generate QR codes
|
||||
- `pixel_tools` - Manage tracking pixels
|
||||
- `project_tools` - Organize into projects
|
||||
- `notification_tools` - Manage notification handlers
|
||||
- `submission_tools` - Manage form submissions
|
||||
- `pwa_tools` - Configure PWA
|
||||
|
||||
### AnalyticsTools
|
||||
Query website analytics data:
|
||||
- `list_websites` - List tracked websites
|
||||
- `get_stats` - Get pageviews, visitors, bounce rate
|
||||
- `top_pages` - Get most visited pages
|
||||
- `traffic_sources` - Get referrers and UTM campaigns
|
||||
- `realtime` - Get current active visitors
|
||||
|
||||
### PushNotificationTools
|
||||
Manage push notification campaigns:
|
||||
- `list_websites` - List push-enabled websites
|
||||
- `list_campaigns` - List notification campaigns
|
||||
- `get_campaign` - Get campaign details and stats
|
||||
- `create_campaign` - Create a new campaign (as draft)
|
||||
- `subscriber_stats` - Get subscriber demographics
|
||||
|
||||
### Social Proof (TrustHost - trust_tools)
|
||||
Manage social proof widgets and campaigns:
|
||||
- `trust_campaign_tools` action=list: List campaigns
|
||||
- `trust_notification_tools` action=list: List widgets
|
||||
- `trust_analytics_tools` action=stats: Get performance stats
|
||||
|
||||
### AnalyticsTools
|
||||
Query website analytics data:
|
||||
MARKDOWN;
|
||||
|
||||
protected array $tools = [
|
||||
// BioHost tools
|
||||
\Core\Mod\Web\Mcp\Tools\BioLinkTools::class,
|
||||
\Core\Mod\Web\Mcp\Tools\AnalyticsTools::class,
|
||||
\Core\Mod\Web\Mcp\Tools\DomainTools::class,
|
||||
\Core\Mod\Web\Mcp\Tools\ProjectTools::class,
|
||||
\Core\Mod\Web\Mcp\Tools\PixelTools::class,
|
||||
\Core\Mod\Web\Mcp\Tools\QrTools::class,
|
||||
\Core\Mod\Web\Mcp\Tools\ThemeTools::class,
|
||||
\Core\Mod\Web\Mcp\Tools\NotificationTools::class,
|
||||
\Core\Mod\Web\Mcp\Tools\SubmissionTools::class,
|
||||
\Core\Mod\Web\Mcp\Tools\TemplateTools::class,
|
||||
\Core\Mod\Web\Mcp\Tools\StaticPageTools::class,
|
||||
\Core\Mod\Web\Mcp\Tools\PwaTools::class,
|
||||
|
||||
// Other Marketing tools
|
||||
AnalyticsTools::class,
|
||||
PushNotificationTools::class,
|
||||
\Core\Mod\Trust\Mcp\Tools\CampaignTools::class,
|
||||
\Core\Mod\Trust\Mcp\Tools\NotificationTools::class,
|
||||
\Core\Mod\Trust\Mcp\Tools\AnalyticsTools::class,
|
||||
];
|
||||
|
||||
protected array $resources = [];
|
||||
|
||||
protected array $prompts = [];
|
||||
}
|
||||
336
packages/core-mcp/src/Mod/Mcp/Services/AgentSessionService.php
Normal file
336
packages/core-mcp/src/Mod/Mcp/Services/AgentSessionService.php
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Mcp\Services;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Mod\Agentic\Models\AgentPlan;
|
||||
use Mod\Agentic\Models\AgentSession;
|
||||
|
||||
/**
|
||||
* Agent Session Service - manages session persistence for agent continuity.
|
||||
*
|
||||
* Provides session creation, retrieval, and resumption capabilities
|
||||
* for multi-agent handoff and long-running tasks.
|
||||
*/
|
||||
class AgentSessionService
|
||||
{
|
||||
/**
|
||||
* Cache prefix for session state.
|
||||
*/
|
||||
protected const CACHE_PREFIX = 'mcp_session:';
|
||||
|
||||
/**
|
||||
* Get the cache TTL from config.
|
||||
*/
|
||||
protected function getCacheTtl(): int
|
||||
{
|
||||
return (int) config('mcp.session.cache_ttl', 86400);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new session.
|
||||
*/
|
||||
public function start(
|
||||
string $agentType,
|
||||
?AgentPlan $plan = null,
|
||||
?int $workspaceId = null,
|
||||
array $initialContext = []
|
||||
): AgentSession {
|
||||
$session = AgentSession::start($plan, $agentType);
|
||||
|
||||
if ($workspaceId !== null) {
|
||||
$session->update(['workspace_id' => $workspaceId]);
|
||||
}
|
||||
|
||||
if (! empty($initialContext)) {
|
||||
$session->updateContextSummary($initialContext);
|
||||
}
|
||||
|
||||
// Cache the active session ID for quick lookup
|
||||
$this->cacheActiveSession($session);
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an active session by ID.
|
||||
*/
|
||||
public function get(string $sessionId): ?AgentSession
|
||||
{
|
||||
return AgentSession::where('session_id', $sessionId)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume an existing session.
|
||||
*/
|
||||
public function resume(string $sessionId): ?AgentSession
|
||||
{
|
||||
$session = $this->get($sessionId);
|
||||
|
||||
if (! $session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only resume if paused or was handed off
|
||||
if ($session->status === AgentSession::STATUS_PAUSED) {
|
||||
$session->resume();
|
||||
}
|
||||
|
||||
// Update activity timestamp
|
||||
$session->touchActivity();
|
||||
|
||||
// Cache as active
|
||||
$this->cacheActiveSession($session);
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active sessions for a workspace.
|
||||
*/
|
||||
public function getActiveSessions(?int $workspaceId = null): Collection
|
||||
{
|
||||
$query = AgentSession::active();
|
||||
|
||||
if ($workspaceId !== null) {
|
||||
$query->where('workspace_id', $workspaceId);
|
||||
}
|
||||
|
||||
return $query->orderBy('last_active_at', 'desc')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sessions for a specific plan.
|
||||
*/
|
||||
public function getSessionsForPlan(AgentPlan $plan): Collection
|
||||
{
|
||||
return AgentSession::forPlan($plan)
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recent session for a plan.
|
||||
*/
|
||||
public function getLatestSessionForPlan(AgentPlan $plan): ?AgentSession
|
||||
{
|
||||
return AgentSession::forPlan($plan)
|
||||
->orderBy('created_at', 'desc')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* End a session.
|
||||
*/
|
||||
public function end(string $sessionId, string $status = AgentSession::STATUS_COMPLETED, ?string $summary = null): ?AgentSession
|
||||
{
|
||||
$session = $this->get($sessionId);
|
||||
|
||||
if (! $session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$session->end($status, $summary);
|
||||
|
||||
// Remove from active cache
|
||||
$this->clearCachedSession($session);
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause a session for later resumption.
|
||||
*/
|
||||
public function pause(string $sessionId): ?AgentSession
|
||||
{
|
||||
$session = $this->get($sessionId);
|
||||
|
||||
if (! $session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$session->pause();
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a session for handoff to another agent.
|
||||
*/
|
||||
public function prepareHandoff(
|
||||
string $sessionId,
|
||||
string $summary,
|
||||
array $nextSteps = [],
|
||||
array $blockers = [],
|
||||
array $contextForNext = []
|
||||
): ?AgentSession {
|
||||
$session = $this->get($sessionId);
|
||||
|
||||
if (! $session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$session->prepareHandoff($summary, $nextSteps, $blockers, $contextForNext);
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get handoff context from a session.
|
||||
*/
|
||||
public function getHandoffContext(string $sessionId): ?array
|
||||
{
|
||||
$session = $this->get($sessionId);
|
||||
|
||||
if (! $session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $session->getHandoffContext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a follow-up session continuing from a previous one.
|
||||
*/
|
||||
public function continueFrom(string $previousSessionId, string $newAgentType): ?AgentSession
|
||||
{
|
||||
$previousSession = $this->get($previousSessionId);
|
||||
|
||||
if (! $previousSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the handoff context
|
||||
$handoffContext = $previousSession->getHandoffContext();
|
||||
|
||||
// Create new session with context from previous
|
||||
$newSession = $this->start(
|
||||
$newAgentType,
|
||||
$previousSession->plan,
|
||||
$previousSession->workspace_id,
|
||||
[
|
||||
'continued_from' => $previousSessionId,
|
||||
'previous_agent' => $previousSession->agent_type,
|
||||
'handoff_notes' => $handoffContext['handoff_notes'] ?? null,
|
||||
'inherited_context' => $handoffContext['context_summary'] ?? null,
|
||||
]
|
||||
);
|
||||
|
||||
// Mark previous session as handed off
|
||||
$previousSession->end('handed_off', 'Handed off to '.$newAgentType);
|
||||
|
||||
return $newSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store custom state in session cache for fast access.
|
||||
*/
|
||||
public function setState(string $sessionId, string $key, mixed $value, ?int $ttl = null): void
|
||||
{
|
||||
$cacheKey = self::CACHE_PREFIX.$sessionId.':'.$key;
|
||||
Cache::put($cacheKey, $value, $ttl ?? $this->getCacheTtl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom state from session cache.
|
||||
*/
|
||||
public function getState(string $sessionId, string $key, mixed $default = null): mixed
|
||||
{
|
||||
$cacheKey = self::CACHE_PREFIX.$sessionId.':'.$key;
|
||||
|
||||
return Cache::get($cacheKey, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session exists and is valid.
|
||||
*/
|
||||
public function exists(string $sessionId): bool
|
||||
{
|
||||
return AgentSession::where('session_id', $sessionId)->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session is active.
|
||||
*/
|
||||
public function isActive(string $sessionId): bool
|
||||
{
|
||||
$session = $this->get($sessionId);
|
||||
|
||||
return $session !== null && $session->isActive();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session statistics.
|
||||
*/
|
||||
public function getSessionStats(?int $workspaceId = null, int $days = 7): array
|
||||
{
|
||||
$query = AgentSession::where('created_at', '>=', now()->subDays($days));
|
||||
|
||||
if ($workspaceId !== null) {
|
||||
$query->where('workspace_id', $workspaceId);
|
||||
}
|
||||
|
||||
$sessions = $query->get();
|
||||
|
||||
$byStatus = $sessions->groupBy('status')->map->count();
|
||||
$byAgent = $sessions->groupBy('agent_type')->map->count();
|
||||
|
||||
$completedSessions = $sessions->where('status', AgentSession::STATUS_COMPLETED);
|
||||
$avgDuration = $completedSessions->avg(fn ($s) => $s->getDuration() ?? 0);
|
||||
|
||||
return [
|
||||
'total' => $sessions->count(),
|
||||
'active' => $sessions->where('status', AgentSession::STATUS_ACTIVE)->count(),
|
||||
'by_status' => $byStatus->toArray(),
|
||||
'by_agent_type' => $byAgent->toArray(),
|
||||
'avg_duration_minutes' => round($avgDuration, 1),
|
||||
'period_days' => $days,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up stale sessions (active but not touched in X hours).
|
||||
*/
|
||||
public function cleanupStaleSessions(int $hoursInactive = 24): int
|
||||
{
|
||||
$cutoff = now()->subHours($hoursInactive);
|
||||
|
||||
$staleSessions = AgentSession::active()
|
||||
->where('last_active_at', '<', $cutoff)
|
||||
->get();
|
||||
|
||||
foreach ($staleSessions as $session) {
|
||||
$session->fail('Session timed out due to inactivity');
|
||||
$this->clearCachedSession($session);
|
||||
}
|
||||
|
||||
return $staleSessions->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache the active session for quick lookup.
|
||||
*/
|
||||
protected function cacheActiveSession(AgentSession $session): void
|
||||
{
|
||||
$cacheKey = self::CACHE_PREFIX.'active:'.$session->session_id;
|
||||
Cache::put($cacheKey, [
|
||||
'session_id' => $session->session_id,
|
||||
'agent_type' => $session->agent_type,
|
||||
'plan_id' => $session->agent_plan_id,
|
||||
'workspace_id' => $session->workspace_id,
|
||||
'started_at' => $session->started_at?->toIso8601String(),
|
||||
], $this->getCacheTtl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached session data.
|
||||
*/
|
||||
protected function clearCachedSession(AgentSession $session): void
|
||||
{
|
||||
$cacheKey = self::CACHE_PREFIX.'active:'.$session->session_id;
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
}
|
||||
208
packages/core-mcp/src/Mod/Mcp/Services/AgentToolRegistry.php
Normal file
208
packages/core-mcp/src/Mod/Mcp/Services/AgentToolRegistry.php
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Mcp\Services;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Mod\Api\Models\ApiKey;
|
||||
use Mod\Mcp\Tools\Agent\Contracts\AgentToolInterface;
|
||||
|
||||
/**
|
||||
* Registry for MCP Agent Server tools.
|
||||
*
|
||||
* Provides discovery, permission checking, and execution
|
||||
* of registered agent tools.
|
||||
*/
|
||||
class AgentToolRegistry
|
||||
{
|
||||
/**
|
||||
* Registered tools indexed by name.
|
||||
*
|
||||
* @var array<string, AgentToolInterface>
|
||||
*/
|
||||
protected array $tools = [];
|
||||
|
||||
/**
|
||||
* Register a tool.
|
||||
*/
|
||||
public function register(AgentToolInterface $tool): self
|
||||
{
|
||||
$this->tools[$tool->name()] = $tool;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register multiple tools at once.
|
||||
*
|
||||
* @param array<AgentToolInterface> $tools
|
||||
*/
|
||||
public function registerMany(array $tools): self
|
||||
{
|
||||
foreach ($tools as $tool) {
|
||||
$this->register($tool);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool is registered.
|
||||
*/
|
||||
public function has(string $name): bool
|
||||
{
|
||||
return isset($this->tools[$name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a tool by name.
|
||||
*/
|
||||
public function get(string $name): ?AgentToolInterface
|
||||
{
|
||||
return $this->tools[$name] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered tools.
|
||||
*
|
||||
* @return Collection<string, AgentToolInterface>
|
||||
*/
|
||||
public function all(): Collection
|
||||
{
|
||||
return collect($this->tools);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tools filtered by category.
|
||||
*
|
||||
* @return Collection<string, AgentToolInterface>
|
||||
*/
|
||||
public function byCategory(string $category): Collection
|
||||
{
|
||||
return $this->all()->filter(
|
||||
fn (AgentToolInterface $tool) => $tool->category() === $category
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tools accessible by an API key.
|
||||
*
|
||||
* @return Collection<string, AgentToolInterface>
|
||||
*/
|
||||
public function forApiKey(ApiKey $apiKey): Collection
|
||||
{
|
||||
return $this->all()->filter(function (AgentToolInterface $tool) use ($apiKey) {
|
||||
// Check if API key has required scopes
|
||||
foreach ($tool->requiredScopes() as $scope) {
|
||||
if (! $apiKey->hasScope($scope)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if API key has tool-level permission
|
||||
return $this->apiKeyCanAccessTool($apiKey, $tool->name());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an API key can access a specific tool.
|
||||
*/
|
||||
public function apiKeyCanAccessTool(ApiKey $apiKey, string $toolName): bool
|
||||
{
|
||||
$allowedTools = $apiKey->tool_scopes ?? null;
|
||||
|
||||
// Null means all tools allowed
|
||||
if ($allowedTools === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($toolName, $allowedTools, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tool with permission checking.
|
||||
*
|
||||
* @param string $name Tool name
|
||||
* @param array $args Tool arguments
|
||||
* @param array $context Execution context
|
||||
* @param ApiKey|null $apiKey Optional API key for permission checking
|
||||
* @return array Tool result
|
||||
*
|
||||
* @throws \InvalidArgumentException If tool not found
|
||||
* @throws \RuntimeException If permission denied
|
||||
*/
|
||||
public function execute(string $name, array $args, array $context = [], ?ApiKey $apiKey = null): array
|
||||
{
|
||||
$tool = $this->get($name);
|
||||
|
||||
if (! $tool) {
|
||||
throw new \InvalidArgumentException("Unknown tool: {$name}");
|
||||
}
|
||||
|
||||
// Permission check if API key provided
|
||||
if ($apiKey !== null) {
|
||||
// Check scopes
|
||||
foreach ($tool->requiredScopes() as $scope) {
|
||||
if (! $apiKey->hasScope($scope)) {
|
||||
throw new \RuntimeException(
|
||||
"Permission denied: API key missing scope '{$scope}' for tool '{$name}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check tool-level permission
|
||||
if (! $this->apiKeyCanAccessTool($apiKey, $name)) {
|
||||
throw new \RuntimeException(
|
||||
"Permission denied: API key does not have access to tool '{$name}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $tool->handle($args, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tools as MCP tool definitions.
|
||||
*
|
||||
* @param ApiKey|null $apiKey Filter by API key permissions
|
||||
*/
|
||||
public function toMcpDefinitions(?ApiKey $apiKey = null): array
|
||||
{
|
||||
$tools = $apiKey !== null
|
||||
? $this->forApiKey($apiKey)
|
||||
: $this->all();
|
||||
|
||||
return $tools->map(fn (AgentToolInterface $tool) => $tool->toMcpDefinition())
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tool categories with counts.
|
||||
*/
|
||||
public function categories(): Collection
|
||||
{
|
||||
return $this->all()
|
||||
->groupBy(fn (AgentToolInterface $tool) => $tool->category())
|
||||
->map(fn ($tools) => $tools->count());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tool names.
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
public function names(): array
|
||||
{
|
||||
return array_keys($this->tools);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tool count.
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->tools);
|
||||
}
|
||||
}
|
||||
442
packages/core-mcp/src/Mod/Mcp/Services/CircuitBreaker.php
Normal file
442
packages/core-mcp/src/Mod/Mcp/Services/CircuitBreaker.php
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Mcp\Services;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Mod\Mcp\Exceptions\CircuitOpenException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Circuit Breaker for external module dependencies.
|
||||
*
|
||||
* Provides fault tolerance when dependent services (like the Agentic module)
|
||||
* are unavailable. Implements the circuit breaker pattern with three states:
|
||||
* - Closed: Normal operation, requests pass through
|
||||
* - Open: Service is down, requests fail fast
|
||||
* - Half-Open: Testing if service has recovered
|
||||
*
|
||||
* @see https://martinfowler.com/bliki/CircuitBreaker.html
|
||||
*/
|
||||
class CircuitBreaker
|
||||
{
|
||||
/**
|
||||
* Cache key prefix for circuit state.
|
||||
*/
|
||||
protected const CACHE_PREFIX = 'circuit_breaker:';
|
||||
|
||||
/**
|
||||
* Circuit states.
|
||||
*/
|
||||
public const STATE_CLOSED = 'closed';
|
||||
|
||||
public const STATE_OPEN = 'open';
|
||||
|
||||
public const STATE_HALF_OPEN = 'half_open';
|
||||
|
||||
/**
|
||||
* Default TTL for success/failure counters (seconds).
|
||||
*/
|
||||
protected const COUNTER_TTL = 300;
|
||||
|
||||
/**
|
||||
* Execute a callable with circuit breaker protection.
|
||||
*
|
||||
* @param string $service Service identifier (e.g., 'agentic', 'content')
|
||||
* @param Closure $operation The operation to execute
|
||||
* @param Closure|null $fallback Optional fallback when circuit is open
|
||||
* @return mixed The operation result or fallback value
|
||||
*
|
||||
* @throws CircuitOpenException When circuit is open and no fallback provided
|
||||
* @throws Throwable When operation fails and circuit records the failure
|
||||
*/
|
||||
public function call(string $service, Closure $operation, ?Closure $fallback = null): mixed
|
||||
{
|
||||
$state = $this->getState($service);
|
||||
|
||||
// Fast fail when circuit is open
|
||||
if ($state === self::STATE_OPEN) {
|
||||
Log::debug("Circuit breaker open for {$service}, failing fast");
|
||||
|
||||
if ($fallback !== null) {
|
||||
return $fallback();
|
||||
}
|
||||
|
||||
throw new CircuitOpenException($service);
|
||||
}
|
||||
|
||||
// Handle half-open state with trial lock to prevent concurrent trial requests
|
||||
$hasTrialLock = false;
|
||||
if ($state === self::STATE_HALF_OPEN) {
|
||||
$hasTrialLock = $this->acquireTrialLock($service);
|
||||
|
||||
if (! $hasTrialLock) {
|
||||
// Another request is already testing the service, fail fast
|
||||
Log::debug("Circuit breaker half-open for {$service}, trial in progress, failing fast");
|
||||
|
||||
if ($fallback !== null) {
|
||||
return $fallback();
|
||||
}
|
||||
|
||||
throw new CircuitOpenException($service, "Service '{$service}' is being tested. Please try again shortly.");
|
||||
}
|
||||
}
|
||||
|
||||
// Try the operation
|
||||
try {
|
||||
$result = $operation();
|
||||
|
||||
// Record success and release trial lock if held
|
||||
$this->recordSuccess($service);
|
||||
|
||||
if ($hasTrialLock) {
|
||||
$this->releaseTrialLock($service);
|
||||
}
|
||||
|
||||
return $result;
|
||||
} catch (Throwable $e) {
|
||||
// Release trial lock if held
|
||||
if ($hasTrialLock) {
|
||||
$this->releaseTrialLock($service);
|
||||
}
|
||||
|
||||
// Record failure
|
||||
$this->recordFailure($service, $e);
|
||||
|
||||
// Check if we should trip the circuit
|
||||
if ($this->shouldTrip($service)) {
|
||||
$this->tripCircuit($service);
|
||||
}
|
||||
|
||||
// If fallback provided and this is a recoverable error, use it
|
||||
if ($fallback !== null && $this->isRecoverableError($e)) {
|
||||
Log::warning("Circuit breaker using fallback for {$service}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return $fallback();
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current state of a circuit.
|
||||
*/
|
||||
public function getState(string $service): string
|
||||
{
|
||||
$cacheKey = $this->getStateKey($service);
|
||||
|
||||
$state = Cache::get($cacheKey);
|
||||
|
||||
if ($state === null) {
|
||||
return self::STATE_CLOSED;
|
||||
}
|
||||
|
||||
// Check if open circuit should transition to half-open
|
||||
if ($state === self::STATE_OPEN) {
|
||||
$openedAt = Cache::get($this->getOpenedAtKey($service));
|
||||
$resetTimeout = $this->getResetTimeout($service);
|
||||
|
||||
if ($openedAt && (time() - $openedAt) >= $resetTimeout) {
|
||||
$this->setState($service, self::STATE_HALF_OPEN);
|
||||
|
||||
return self::STATE_HALF_OPEN;
|
||||
}
|
||||
}
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get circuit statistics for monitoring.
|
||||
*/
|
||||
public function getStats(string $service): array
|
||||
{
|
||||
return [
|
||||
'service' => $service,
|
||||
'state' => $this->getState($service),
|
||||
'failures' => (int) Cache::get($this->getFailureCountKey($service), 0),
|
||||
'successes' => (int) Cache::get($this->getSuccessCountKey($service), 0),
|
||||
'last_failure' => Cache::get($this->getLastFailureKey($service)),
|
||||
'opened_at' => Cache::get($this->getOpenedAtKey($service)),
|
||||
'threshold' => $this->getFailureThreshold($service),
|
||||
'reset_timeout' => $this->getResetTimeout($service),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually reset a circuit to closed state.
|
||||
*/
|
||||
public function reset(string $service): void
|
||||
{
|
||||
$this->setState($service, self::STATE_CLOSED);
|
||||
Cache::forget($this->getFailureCountKey($service));
|
||||
Cache::forget($this->getSuccessCountKey($service));
|
||||
Cache::forget($this->getLastFailureKey($service));
|
||||
Cache::forget($this->getOpenedAtKey($service));
|
||||
|
||||
Log::info("Circuit breaker manually reset for {$service}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a service is available (circuit not open).
|
||||
*/
|
||||
public function isAvailable(string $service): bool
|
||||
{
|
||||
return $this->getState($service) !== self::STATE_OPEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a successful operation.
|
||||
*/
|
||||
protected function recordSuccess(string $service): void
|
||||
{
|
||||
$state = $this->getState($service);
|
||||
|
||||
// Increment success counter with TTL
|
||||
$this->atomicIncrement($this->getSuccessCountKey($service), self::COUNTER_TTL);
|
||||
|
||||
// If half-open and we got a success, close the circuit
|
||||
if ($state === self::STATE_HALF_OPEN) {
|
||||
$this->closeCircuit($service);
|
||||
}
|
||||
|
||||
// Decay failures over time (successful calls reduce failure count)
|
||||
$this->atomicDecrement($this->getFailureCountKey($service));
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a failed operation.
|
||||
*/
|
||||
protected function recordFailure(string $service, Throwable $e): void
|
||||
{
|
||||
$failureKey = $this->getFailureCountKey($service);
|
||||
$lastFailureKey = $this->getLastFailureKey($service);
|
||||
$window = $this->getFailureWindow($service);
|
||||
|
||||
// Atomic increment with TTL refresh using lock
|
||||
$newCount = $this->atomicIncrement($failureKey, $window);
|
||||
|
||||
// Record last failure details
|
||||
Cache::put($lastFailureKey, [
|
||||
'message' => $e->getMessage(),
|
||||
'class' => get_class($e),
|
||||
'time' => now()->toIso8601String(),
|
||||
], $window);
|
||||
|
||||
Log::warning("Circuit breaker recorded failure for {$service}", [
|
||||
'error' => $e->getMessage(),
|
||||
'failures' => $newCount,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the circuit should trip (open).
|
||||
*/
|
||||
protected function shouldTrip(string $service): bool
|
||||
{
|
||||
$failures = (int) Cache::get($this->getFailureCountKey($service), 0);
|
||||
$threshold = $this->getFailureThreshold($service);
|
||||
|
||||
return $failures >= $threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trip the circuit to open state.
|
||||
*/
|
||||
protected function tripCircuit(string $service): void
|
||||
{
|
||||
$this->setState($service, self::STATE_OPEN);
|
||||
Cache::put($this->getOpenedAtKey($service), time(), 86400); // 24h max
|
||||
|
||||
Log::error("Circuit breaker tripped for {$service}", [
|
||||
'failures' => Cache::get($this->getFailureCountKey($service)),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the circuit after successful recovery.
|
||||
*/
|
||||
protected function closeCircuit(string $service): void
|
||||
{
|
||||
$this->setState($service, self::STATE_CLOSED);
|
||||
Cache::forget($this->getFailureCountKey($service));
|
||||
Cache::forget($this->getOpenedAtKey($service));
|
||||
|
||||
Log::info("Circuit breaker closed for {$service} after successful recovery");
|
||||
}
|
||||
|
||||
/**
|
||||
* Set circuit state.
|
||||
*/
|
||||
protected function setState(string $service, string $state): void
|
||||
{
|
||||
Cache::put($this->getStateKey($service), $state, 86400); // 24h max
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an exception is recoverable (should use fallback).
|
||||
*/
|
||||
protected function isRecoverableError(Throwable $e): bool
|
||||
{
|
||||
// Database connection errors, table not found, etc.
|
||||
$recoverablePatterns = [
|
||||
'SQLSTATE',
|
||||
'Connection refused',
|
||||
'Table .* doesn\'t exist',
|
||||
'Base table or view not found',
|
||||
'Connection timed out',
|
||||
'Too many connections',
|
||||
];
|
||||
|
||||
$message = $e->getMessage();
|
||||
|
||||
foreach ($recoverablePatterns as $pattern) {
|
||||
if (preg_match('/'.$pattern.'/i', $message)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the failure threshold from config.
|
||||
*/
|
||||
protected function getFailureThreshold(string $service): int
|
||||
{
|
||||
return (int) config("mcp.circuit_breaker.{$service}.threshold",
|
||||
config('mcp.circuit_breaker.default_threshold', 5)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the reset timeout (how long to wait before trying again).
|
||||
*/
|
||||
protected function getResetTimeout(string $service): int
|
||||
{
|
||||
return (int) config("mcp.circuit_breaker.{$service}.reset_timeout",
|
||||
config('mcp.circuit_breaker.default_reset_timeout', 60)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the failure window (how long failures are counted).
|
||||
*/
|
||||
protected function getFailureWindow(string $service): int
|
||||
{
|
||||
return (int) config("mcp.circuit_breaker.{$service}.failure_window",
|
||||
config('mcp.circuit_breaker.default_failure_window', 120)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically increment a counter with TTL refresh.
|
||||
*
|
||||
* Uses a lock to ensure the increment and TTL refresh are atomic.
|
||||
*/
|
||||
protected function atomicIncrement(string $key, int $ttl): int
|
||||
{
|
||||
$lock = Cache::lock($key.':lock', 5);
|
||||
|
||||
try {
|
||||
$lock->block(3);
|
||||
|
||||
$current = (int) Cache::get($key, 0);
|
||||
$newValue = $current + 1;
|
||||
Cache::put($key, $newValue, $ttl);
|
||||
|
||||
return $newValue;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically decrement a counter (only if positive).
|
||||
*
|
||||
* Note: We use COUNTER_TTL as a fallback since Laravel's Cache facade
|
||||
* doesn't expose remaining TTL. The counter will refresh on activity.
|
||||
*/
|
||||
protected function atomicDecrement(string $key): int
|
||||
{
|
||||
$lock = Cache::lock($key.':lock', 5);
|
||||
|
||||
try {
|
||||
$lock->block(3);
|
||||
|
||||
$current = (int) Cache::get($key, 0);
|
||||
if ($current > 0) {
|
||||
$newValue = $current - 1;
|
||||
Cache::put($key, $newValue, self::COUNTER_TTL);
|
||||
|
||||
return $newValue;
|
||||
}
|
||||
|
||||
return 0;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a trial lock for half-open state.
|
||||
*
|
||||
* Only one request can hold the trial lock at a time, preventing
|
||||
* concurrent trial requests during half-open state.
|
||||
*/
|
||||
protected function acquireTrialLock(string $service): bool
|
||||
{
|
||||
$lockKey = $this->getTrialLockKey($service);
|
||||
|
||||
// Try to acquire lock with a short TTL (auto-release if request hangs)
|
||||
return Cache::add($lockKey, true, 30);
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the trial lock.
|
||||
*/
|
||||
protected function releaseTrialLock(string $service): void
|
||||
{
|
||||
Cache::forget($this->getTrialLockKey($service));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the trial lock cache key.
|
||||
*/
|
||||
protected function getTrialLockKey(string $service): string
|
||||
{
|
||||
return self::CACHE_PREFIX.$service.':trial_lock';
|
||||
}
|
||||
|
||||
// Cache key helpers
|
||||
protected function getStateKey(string $service): string
|
||||
{
|
||||
return self::CACHE_PREFIX.$service.':state';
|
||||
}
|
||||
|
||||
protected function getFailureCountKey(string $service): string
|
||||
{
|
||||
return self::CACHE_PREFIX.$service.':failures';
|
||||
}
|
||||
|
||||
protected function getSuccessCountKey(string $service): string
|
||||
{
|
||||
return self::CACHE_PREFIX.$service.':successes';
|
||||
}
|
||||
|
||||
protected function getLastFailureKey(string $service): string
|
||||
{
|
||||
return self::CACHE_PREFIX.$service.':last_failure';
|
||||
}
|
||||
|
||||
protected function getOpenedAtKey(string $service): string
|
||||
{
|
||||
return self::CACHE_PREFIX.$service.':opened_at';
|
||||
}
|
||||
}
|
||||
305
packages/core-mcp/src/Mod/Mcp/Services/DataRedactor.php
Normal file
305
packages/core-mcp/src/Mod/Mcp/Services/DataRedactor.php
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Mcp\Services;
|
||||
|
||||
/**
|
||||
* Data Redactor - redacts sensitive information from tool call logs.
|
||||
*
|
||||
* Prevents PII, credentials, and secrets from being stored in tool call
|
||||
* logs while maintaining enough context for debugging.
|
||||
*/
|
||||
class DataRedactor
|
||||
{
|
||||
/**
|
||||
* Keys that should always be fully redacted.
|
||||
*/
|
||||
protected const SENSITIVE_KEYS = [
|
||||
'password',
|
||||
'passwd',
|
||||
'secret',
|
||||
'token',
|
||||
'api_key',
|
||||
'apikey',
|
||||
'api-key',
|
||||
'auth',
|
||||
'authorization',
|
||||
'bearer',
|
||||
'credential',
|
||||
'credentials',
|
||||
'private_key',
|
||||
'privatekey',
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
'session_token',
|
||||
'jwt',
|
||||
'ssn',
|
||||
'social_security',
|
||||
'credit_card',
|
||||
'creditcard',
|
||||
'card_number',
|
||||
'cvv',
|
||||
'cvc',
|
||||
'pin',
|
||||
'routing_number',
|
||||
'account_number',
|
||||
'bank_account',
|
||||
];
|
||||
|
||||
/**
|
||||
* Keys containing PII that should be partially redacted.
|
||||
*/
|
||||
protected const PII_KEYS = [
|
||||
'email',
|
||||
'phone',
|
||||
'telephone',
|
||||
'mobile',
|
||||
'address',
|
||||
'street',
|
||||
'postcode',
|
||||
'zip',
|
||||
'zipcode',
|
||||
'date_of_birth',
|
||||
'dob',
|
||||
'birthdate',
|
||||
'national_insurance',
|
||||
'ni_number',
|
||||
'passport',
|
||||
'license',
|
||||
'licence',
|
||||
];
|
||||
|
||||
/**
|
||||
* Replacement string for fully redacted values.
|
||||
*/
|
||||
protected const REDACTED = '[REDACTED]';
|
||||
|
||||
/**
|
||||
* Redact sensitive data from an array recursively.
|
||||
*/
|
||||
public function redact(mixed $data, int $maxDepth = 10): mixed
|
||||
{
|
||||
if ($maxDepth <= 0) {
|
||||
return '[MAX_DEPTH_EXCEEDED]';
|
||||
}
|
||||
|
||||
if (is_array($data)) {
|
||||
return $this->redactArray($data, $maxDepth - 1);
|
||||
}
|
||||
|
||||
if (is_string($data)) {
|
||||
return $this->redactString($data);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact sensitive values from an array.
|
||||
*/
|
||||
protected function redactArray(array $data, int $maxDepth): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
$lowerKey = strtolower((string) $key);
|
||||
|
||||
// Check for fully sensitive keys
|
||||
if ($this->isSensitiveKey($lowerKey)) {
|
||||
$result[$key] = self::REDACTED;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for PII keys - partially redact
|
||||
if ($this->isPiiKey($lowerKey) && is_string($value)) {
|
||||
$result[$key] = $this->partialRedact($value);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Recurse into nested arrays (with depth guard)
|
||||
if (is_array($value)) {
|
||||
if ($maxDepth <= 0) {
|
||||
$result[$key] = '[MAX_DEPTH_EXCEEDED]';
|
||||
} else {
|
||||
$result[$key] = $this->redactArray($value, $maxDepth - 1);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check string values for embedded sensitive patterns
|
||||
if (is_string($value)) {
|
||||
$result[$key] = $this->redactString($value);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[$key] = $value;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key name indicates sensitive data.
|
||||
*/
|
||||
protected function isSensitiveKey(string $key): bool
|
||||
{
|
||||
foreach (self::SENSITIVE_KEYS as $sensitiveKey) {
|
||||
if (str_contains($key, $sensitiveKey)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key name indicates PII.
|
||||
*/
|
||||
protected function isPiiKey(string $key): bool
|
||||
{
|
||||
foreach (self::PII_KEYS as $piiKey) {
|
||||
if (str_contains($key, $piiKey)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact sensitive patterns from a string value.
|
||||
*/
|
||||
protected function redactString(string $value): string
|
||||
{
|
||||
// Redact bearer tokens
|
||||
$value = preg_replace(
|
||||
'/Bearer\s+[A-Za-z0-9\-_\.]+/i',
|
||||
'Bearer '.self::REDACTED,
|
||||
$value
|
||||
) ?? $value;
|
||||
|
||||
// Redact Basic auth
|
||||
$value = preg_replace(
|
||||
'/Basic\s+[A-Za-z0-9\+\/=]+/i',
|
||||
'Basic '.self::REDACTED,
|
||||
$value
|
||||
) ?? $value;
|
||||
|
||||
// Redact common API key patterns (key_xxx, sk_xxx, pk_xxx)
|
||||
$value = preg_replace(
|
||||
'/\b(sk|pk|key|api|token)_[a-zA-Z0-9]{16,}/i',
|
||||
'$1_'.self::REDACTED,
|
||||
$value
|
||||
) ?? $value;
|
||||
|
||||
// Redact JWT tokens (xxx.xxx.xxx format with base64)
|
||||
$value = preg_replace(
|
||||
'/eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*/i',
|
||||
self::REDACTED,
|
||||
$value
|
||||
) ?? $value;
|
||||
|
||||
// Redact UK National Insurance numbers
|
||||
$value = preg_replace(
|
||||
'/[A-Z]{2}\s?\d{2}\s?\d{2}\s?\d{2}\s?[A-Z]/i',
|
||||
self::REDACTED,
|
||||
$value
|
||||
) ?? $value;
|
||||
|
||||
// Redact credit card numbers (basic pattern)
|
||||
$value = preg_replace(
|
||||
'/\b\d{4}[\s\-]?\d{4}[\s\-]?\d{4}[\s\-]?\d{4}\b/',
|
||||
self::REDACTED,
|
||||
$value
|
||||
) ?? $value;
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Partially redact a value, showing first and last characters.
|
||||
*/
|
||||
protected function partialRedact(string $value): string
|
||||
{
|
||||
$length = strlen($value);
|
||||
|
||||
if ($length <= 4) {
|
||||
return self::REDACTED;
|
||||
}
|
||||
|
||||
if ($length <= 8) {
|
||||
return substr($value, 0, 2).'***'.substr($value, -1);
|
||||
}
|
||||
|
||||
// For longer values, show more context
|
||||
$showChars = min(3, (int) floor($length / 4));
|
||||
|
||||
return substr($value, 0, $showChars).'***'.substr($value, -$showChars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a summary of array data without sensitive information.
|
||||
*
|
||||
* Useful for result_summary where we want structure info without details.
|
||||
*/
|
||||
public function summarize(mixed $data, int $maxDepth = 3): mixed
|
||||
{
|
||||
if ($maxDepth <= 0) {
|
||||
return '[...]';
|
||||
}
|
||||
|
||||
if (is_array($data)) {
|
||||
$result = [];
|
||||
$count = count($data);
|
||||
|
||||
// Limit array size in summary
|
||||
$limit = 10;
|
||||
$truncated = $count > $limit;
|
||||
$items = array_slice($data, 0, $limit, true);
|
||||
|
||||
foreach ($items as $key => $value) {
|
||||
$lowerKey = strtolower((string) $key);
|
||||
|
||||
// Fully redact sensitive keys
|
||||
if ($this->isSensitiveKey($lowerKey)) {
|
||||
$result[$key] = self::REDACTED;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Partially redact PII keys
|
||||
if ($this->isPiiKey($lowerKey) && is_string($value)) {
|
||||
$result[$key] = $this->partialRedact($value);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Recurse with reduced depth
|
||||
$result[$key] = $this->summarize($value, $maxDepth - 1);
|
||||
}
|
||||
|
||||
if ($truncated) {
|
||||
$result['_truncated'] = '... and '.($count - $limit).' more items';
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
if (is_string($data)) {
|
||||
// Redact first, then truncate (prevents leaking sensitive patterns)
|
||||
$redacted = $this->redactString($data);
|
||||
if (strlen($redacted) > 100) {
|
||||
return substr($redacted, 0, 97).'...';
|
||||
}
|
||||
|
||||
return $redacted;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
144
packages/core-mcp/src/Mod/Mcp/Services/ToolRateLimiter.php
Normal file
144
packages/core-mcp/src/Mod/Mcp/Services/ToolRateLimiter.php
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mod\Mcp\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Rate limiter for MCP tool calls.
|
||||
*
|
||||
* Provides rate limiting for both HTTP and STDIO server tool invocations.
|
||||
* Uses cache-based rate limiting that works with any cache driver.
|
||||
*/
|
||||
class ToolRateLimiter
|
||||
{
|
||||
/**
|
||||
* Cache key prefix for rate limit tracking.
|
||||
*/
|
||||
protected const CACHE_PREFIX = 'mcp_rate_limit:';
|
||||
|
||||
/**
|
||||
* Check if a tool call should be rate limited.
|
||||
*
|
||||
* @param string $identifier Session ID, API key, or other unique identifier
|
||||
* @param string $toolName The tool being called
|
||||
* @return array{limited: bool, remaining: int, retry_after: int|null}
|
||||
*/
|
||||
public function check(string $identifier, string $toolName): array
|
||||
{
|
||||
if (! config('mcp.rate_limiting.enabled', true)) {
|
||||
return ['limited' => false, 'remaining' => PHP_INT_MAX, 'retry_after' => null];
|
||||
}
|
||||
|
||||
$limit = $this->getLimitForTool($toolName);
|
||||
$decaySeconds = config('mcp.rate_limiting.decay_seconds', 60);
|
||||
$cacheKey = $this->getCacheKey($identifier, $toolName);
|
||||
|
||||
$current = (int) Cache::get($cacheKey, 0);
|
||||
|
||||
if ($current >= $limit) {
|
||||
$ttl = Cache::ttl($cacheKey);
|
||||
|
||||
return [
|
||||
'limited' => true,
|
||||
'remaining' => 0,
|
||||
'retry_after' => $ttl > 0 ? $ttl : $decaySeconds,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'limited' => false,
|
||||
'remaining' => $limit - $current - 1,
|
||||
'retry_after' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a tool call against the rate limit.
|
||||
*
|
||||
* @param string $identifier Session ID, API key, or other unique identifier
|
||||
* @param string $toolName The tool being called
|
||||
*/
|
||||
public function hit(string $identifier, string $toolName): void
|
||||
{
|
||||
if (! config('mcp.rate_limiting.enabled', true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$decaySeconds = config('mcp.rate_limiting.decay_seconds', 60);
|
||||
$cacheKey = $this->getCacheKey($identifier, $toolName);
|
||||
|
||||
$current = (int) Cache::get($cacheKey, 0);
|
||||
|
||||
if ($current === 0) {
|
||||
// First call - set with expiration
|
||||
Cache::put($cacheKey, 1, $decaySeconds);
|
||||
} else {
|
||||
// Increment without resetting TTL
|
||||
Cache::increment($cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear rate limit for an identifier.
|
||||
*
|
||||
* @param string $identifier Session ID, API key, or other unique identifier
|
||||
* @param string|null $toolName Specific tool, or null to clear all
|
||||
*/
|
||||
public function clear(string $identifier, ?string $toolName = null): void
|
||||
{
|
||||
if ($toolName !== null) {
|
||||
Cache::forget($this->getCacheKey($identifier, $toolName));
|
||||
} else {
|
||||
// Clear all tool rate limits for this identifier (requires knowing tools)
|
||||
// For now, just clear the specific key pattern
|
||||
Cache::forget($this->getCacheKey($identifier, '*'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the rate limit for a specific tool.
|
||||
*/
|
||||
protected function getLimitForTool(string $toolName): int
|
||||
{
|
||||
// Check for tool-specific limit
|
||||
$perToolLimits = config('mcp.rate_limiting.per_tool', []);
|
||||
|
||||
if (isset($perToolLimits[$toolName])) {
|
||||
return (int) $perToolLimits[$toolName];
|
||||
}
|
||||
|
||||
// Use default limit
|
||||
return (int) config('mcp.rate_limiting.calls_per_minute', 60);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key for rate limiting.
|
||||
*/
|
||||
protected function getCacheKey(string $identifier, string $toolName): string
|
||||
{
|
||||
// Use general key for overall rate limiting
|
||||
return self::CACHE_PREFIX.$identifier.':'.$toolName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rate limit status for reporting.
|
||||
*
|
||||
* @return array{limit: int, remaining: int, reset_at: string|null}
|
||||
*/
|
||||
public function getStatus(string $identifier, string $toolName): array
|
||||
{
|
||||
$limit = $this->getLimitForTool($toolName);
|
||||
$cacheKey = $this->getCacheKey($identifier, $toolName);
|
||||
$current = (int) Cache::get($cacheKey, 0);
|
||||
$ttl = Cache::ttl($cacheKey);
|
||||
|
||||
return [
|
||||
'limit' => $limit,
|
||||
'remaining' => max(0, $limit - $current),
|
||||
'reset_at' => $ttl > 0 ? now()->addSeconds($ttl)->toIso8601String() : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
39
packages/core-php/TODO.md
Normal file
39
packages/core-php/TODO.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Core-PHP TODO
|
||||
|
||||
## Seeder Auto-Discovery
|
||||
|
||||
**Priority:** Medium
|
||||
**Context:** Currently apps need a `database/seeders/DatabaseSeeder.php` that manually lists module seeders in order. This is boilerplate that core-php could handle.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Auto-discover seeders from registered modules (`*/Database/Seeders/*Seeder.php`)
|
||||
- Support priority ordering via property or attribute (e.g., `public int $priority = 50`)
|
||||
- Support dependency ordering via `$after` or `$before` arrays
|
||||
- Provide base `DatabaseSeeder` class that apps can extend or use directly
|
||||
- Allow apps to override/exclude specific seeders if needed
|
||||
|
||||
### Example
|
||||
|
||||
```php
|
||||
// In a module seeder
|
||||
class FeatureSeeder extends Seeder
|
||||
{
|
||||
public int $priority = 10; // Run early
|
||||
|
||||
public function run(): void { ... }
|
||||
}
|
||||
|
||||
class PackageSeeder extends Seeder
|
||||
{
|
||||
public array $after = [FeatureSeeder::class]; // Run after features
|
||||
|
||||
public function run(): void { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- Current Host Hub DatabaseSeeder has ~20 seeders with implicit ordering
|
||||
- Key dependencies: features → packages → workspaces → system user → content
|
||||
- Could use Laravel's service container to resolve seeder graph
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
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 bouncer tables - IP blocking, rate limiting, redirects.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::disableForeignKeyConstraints();
|
||||
|
||||
// 1. Blocked IPs
|
||||
Schema::create('blocked_ips', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('ip_address', 45);
|
||||
$table->string('ip_range', 18)->nullable();
|
||||
$table->string('reason')->nullable();
|
||||
$table->string('source', 32)->default('manual');
|
||||
$table->string('status', 32)->default('active');
|
||||
$table->unsignedInteger('hit_count')->default(0);
|
||||
$table->timestamp('expires_at')->nullable();
|
||||
$table->timestamp('last_hit_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['ip_address', 'ip_range']);
|
||||
$table->index(['status', 'expires_at']);
|
||||
$table->index('ip_address');
|
||||
});
|
||||
|
||||
// 2. Rate Limit Buckets
|
||||
Schema::create('rate_limit_buckets', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('key');
|
||||
$table->string('bucket_type', 32);
|
||||
$table->unsignedInteger('tokens')->default(0);
|
||||
$table->unsignedInteger('max_tokens');
|
||||
$table->timestamp('last_refill_at');
|
||||
$table->timestamp('expires_at');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['key', 'bucket_type']);
|
||||
$table->index('expires_at');
|
||||
});
|
||||
|
||||
Schema::enableForeignKeyConstraints();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::disableForeignKeyConstraints();
|
||||
Schema::dropIfExists('rate_limit_buckets');
|
||||
Schema::dropIfExists('blocked_ips');
|
||||
Schema::enableForeignKeyConstraints();
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
<?php
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
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 config tables - hierarchical configuration management.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::disableForeignKeyConstraints();
|
||||
|
||||
// 1. Config Keys (definitions)
|
||||
Schema::create('config_keys', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('code')->unique();
|
||||
$table->foreignId('parent_id')->nullable()
|
||||
->constrained('config_keys')
|
||||
->nullOnDelete();
|
||||
$table->string('type')->default('string');
|
||||
$table->string('category')->index();
|
||||
$table->string('description')->nullable();
|
||||
$table->json('default_value')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['category', 'code']);
|
||||
});
|
||||
|
||||
// 2. Config Profiles (scope containers)
|
||||
Schema::create('config_profiles', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('scope_type')->index();
|
||||
$table->unsignedBigInteger('scope_id')->nullable()->index();
|
||||
$table->foreignId('parent_profile_id')->nullable()
|
||||
->constrained('config_profiles')
|
||||
->nullOnDelete();
|
||||
$table->integer('priority')->default(0);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['scope_type', 'scope_id']);
|
||||
$table->unique(['scope_type', 'scope_id', 'priority']);
|
||||
});
|
||||
|
||||
// 3. Config Values
|
||||
Schema::create('config_values', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('profile_id')
|
||||
->constrained('config_profiles')
|
||||
->cascadeOnDelete();
|
||||
$table->foreignId('key_id')
|
||||
->constrained('config_keys')
|
||||
->cascadeOnDelete();
|
||||
$table->json('value')->nullable();
|
||||
$table->boolean('locked')->default(false);
|
||||
$table->foreignId('inherited_from')->nullable()
|
||||
->constrained('config_profiles')
|
||||
->nullOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['profile_id', 'key_id']);
|
||||
$table->index(['key_id', 'locked']);
|
||||
});
|
||||
|
||||
// 4. Config Channels
|
||||
Schema::create('config_channels', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('code')->unique();
|
||||
$table->string('type')->default('notification');
|
||||
$table->json('settings')->nullable();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->integer('sort_order')->default(0);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['type', 'is_active']);
|
||||
});
|
||||
|
||||
// 5. Config Resolved Cache
|
||||
Schema::create('config_resolved', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('scope_type');
|
||||
$table->unsignedBigInteger('scope_id');
|
||||
$table->string('key_code');
|
||||
$table->json('resolved_value')->nullable();
|
||||
$table->foreignId('source_profile_id')->nullable()
|
||||
->constrained('config_profiles')
|
||||
->nullOnDelete();
|
||||
$table->timestamp('resolved_at');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['scope_type', 'scope_id', 'key_code'], 'config_resolved_unique');
|
||||
$table->index(['scope_type', 'scope_id']);
|
||||
$table->index('key_code');
|
||||
});
|
||||
|
||||
Schema::enableForeignKeyConstraints();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::disableForeignKeyConstraints();
|
||||
Schema::dropIfExists('config_resolved');
|
||||
Schema::dropIfExists('config_channels');
|
||||
Schema::dropIfExists('config_values');
|
||||
Schema::dropIfExists('config_profiles');
|
||||
Schema::dropIfExists('config_keys');
|
||||
Schema::enableForeignKeyConstraints();
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\LazyModuleListener;
|
||||
|
||||
describe('LazyModuleListener', function () {
|
||||
it('stores module class and method', function () {
|
||||
$listener = new LazyModuleListener(
|
||||
TestLazyModule::class,
|
||||
'handleEvent'
|
||||
);
|
||||
|
||||
expect($listener->getModuleClass())->toBe(TestLazyModule::class);
|
||||
expect($listener->getMethod())->toBe('handleEvent');
|
||||
});
|
||||
|
||||
it('invokes the module method when called', function () {
|
||||
TestLazyModule::$called = false;
|
||||
TestLazyModule::$receivedEvent = null;
|
||||
|
||||
$listener = new LazyModuleListener(
|
||||
TestLazyModule::class,
|
||||
'handleEvent'
|
||||
);
|
||||
|
||||
$event = new TestEvent('test data');
|
||||
$listener($event);
|
||||
|
||||
expect(TestLazyModule::$called)->toBeTrue();
|
||||
expect(TestLazyModule::$receivedEvent)->toBe($event);
|
||||
});
|
||||
|
||||
it('reuses the same module instance on multiple calls', function () {
|
||||
TestLazyModule::$instanceCount = 0;
|
||||
|
||||
$listener = new LazyModuleListener(
|
||||
TestLazyModule::class,
|
||||
'handleEvent'
|
||||
);
|
||||
|
||||
$event = new TestEvent('test');
|
||||
$listener($event);
|
||||
$listener($event);
|
||||
$listener($event);
|
||||
|
||||
expect(TestLazyModule::$instanceCount)->toBe(1);
|
||||
});
|
||||
|
||||
it('handle method is alias for __invoke', function () {
|
||||
TestLazyModule::$called = false;
|
||||
TestLazyModule::$receivedEvent = null;
|
||||
|
||||
$listener = new LazyModuleListener(
|
||||
TestLazyModule::class,
|
||||
'handleEvent'
|
||||
);
|
||||
|
||||
$event = new TestEvent('handle test');
|
||||
$listener->handle($event);
|
||||
|
||||
expect(TestLazyModule::$called)->toBeTrue();
|
||||
expect(TestLazyModule::$receivedEvent)->toBe($event);
|
||||
});
|
||||
});
|
||||
|
||||
// Test fixtures
|
||||
|
||||
class TestEvent
|
||||
{
|
||||
public function __construct(public string $data) {}
|
||||
}
|
||||
|
||||
class TestLazyModule
|
||||
{
|
||||
public static bool $called = false;
|
||||
|
||||
public static ?TestEvent $receivedEvent = null;
|
||||
|
||||
public static int $instanceCount = 0;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
self::$instanceCount++;
|
||||
}
|
||||
|
||||
public function handleEvent(TestEvent $event): void
|
||||
{
|
||||
self::$called = true;
|
||||
self::$receivedEvent = $event;
|
||||
}
|
||||
}
|
||||
|
||||
92
packages/core-php/src/Core/Tests/Unit/ModuleScannerTest.php
Normal file
92
packages/core-php/src/Core/Tests/Unit/ModuleScannerTest.php
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\ModuleScanner;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->scanner = new ModuleScanner;
|
||||
});
|
||||
|
||||
describe('extractListens', function () {
|
||||
it('extracts $listens from a class with public static property', function () {
|
||||
$listens = $this->scanner->extractListens(TestModuleWithListens::class);
|
||||
|
||||
expect($listens)->toBe([
|
||||
'SomeEvent' => 'handleSomeEvent',
|
||||
'AnotherEvent' => 'onAnother',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array when class has no $listens property', function () {
|
||||
$listens = $this->scanner->extractListens(TestModuleWithoutListens::class);
|
||||
|
||||
expect($listens)->toBe([]);
|
||||
});
|
||||
|
||||
it('returns empty array when $listens is not public', function () {
|
||||
$listens = $this->scanner->extractListens(TestModuleWithPrivateListens::class);
|
||||
|
||||
expect($listens)->toBe([]);
|
||||
});
|
||||
|
||||
it('returns empty array when $listens is not static', function () {
|
||||
$listens = $this->scanner->extractListens(TestModuleWithNonStaticListens::class);
|
||||
|
||||
expect($listens)->toBe([]);
|
||||
});
|
||||
|
||||
it('returns empty array when $listens is not an array', function () {
|
||||
$listens = $this->scanner->extractListens(TestModuleWithStringListens::class);
|
||||
|
||||
expect($listens)->toBe([]);
|
||||
});
|
||||
|
||||
it('returns empty array for non-existent class', function () {
|
||||
$listens = $this->scanner->extractListens('NonExistentClass');
|
||||
|
||||
expect($listens)->toBe([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scan', function () {
|
||||
it('skips non-existent directories', function () {
|
||||
$result = $this->scanner->scan(['/path/that/does/not/exist']);
|
||||
|
||||
expect($result)->toBe([]);
|
||||
});
|
||||
});
|
||||
|
||||
// Test fixtures - these classes are used to test reflection behaviour
|
||||
|
||||
class TestModuleWithListens
|
||||
{
|
||||
public static array $listens = [
|
||||
'SomeEvent' => 'handleSomeEvent',
|
||||
'AnotherEvent' => 'onAnother',
|
||||
];
|
||||
}
|
||||
|
||||
class TestModuleWithoutListens
|
||||
{
|
||||
public function boot(): void {}
|
||||
}
|
||||
|
||||
class TestModuleWithPrivateListens
|
||||
{
|
||||
private static array $listens = [
|
||||
'SomeEvent' => 'handleSomeEvent',
|
||||
];
|
||||
}
|
||||
|
||||
class TestModuleWithNonStaticListens
|
||||
{
|
||||
public array $listens = [
|
||||
'SomeEvent' => 'handleSomeEvent',
|
||||
];
|
||||
}
|
||||
|
||||
class TestModuleWithStringListens
|
||||
{
|
||||
public static string $listens = 'not an array';
|
||||
}
|
||||
247
packages/core-php/src/Mod/Tenant/Concerns/BelongsToNamespace.php
Normal file
247
packages/core-php/src/Mod/Tenant/Concerns/BelongsToNamespace.php
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Concerns;
|
||||
|
||||
use Core\Mod\Tenant\Models\Namespace_;
|
||||
use Core\Mod\Tenant\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Trait for models that belong to a namespace.
|
||||
*
|
||||
* Provides namespace relationship, scoping, and namespace-scoped caching.
|
||||
* This replaces dual workspace_id/user_id ownership with a single namespace_id.
|
||||
*
|
||||
* Usage:
|
||||
* class Page extends Model {
|
||||
* use BelongsToNamespace;
|
||||
* }
|
||||
*
|
||||
* // Get cached collection for current namespace
|
||||
* $pages = Page::ownedByCurrentNamespaceCached();
|
||||
*
|
||||
* // Get query scoped to current namespace
|
||||
* $pages = Page::ownedByCurrentNamespace()->where('is_active', true)->get();
|
||||
*/
|
||||
trait BelongsToNamespace
|
||||
{
|
||||
/**
|
||||
* Boot the trait - sets up auto-assignment of namespace_id and cache invalidation.
|
||||
*/
|
||||
protected static function bootBelongsToNamespace(): void
|
||||
{
|
||||
// Auto-assign namespace_id when creating a model without one
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->namespace_id)) {
|
||||
$namespace = static::getCurrentNamespace();
|
||||
if ($namespace) {
|
||||
$model->namespace_id = $namespace->id;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
static::saved(function ($model) {
|
||||
if ($model->namespace_id) {
|
||||
static::clearNamespaceCache($model->namespace_id);
|
||||
}
|
||||
});
|
||||
|
||||
static::deleted(function ($model) {
|
||||
if ($model->namespace_id) {
|
||||
static::clearNamespaceCache($model->namespace_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the namespace this model belongs to.
|
||||
*/
|
||||
public function namespace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Namespace_::class, 'namespace_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope query to the current namespace.
|
||||
*/
|
||||
public function scopeOwnedByCurrentNamespace(Builder $query): Builder
|
||||
{
|
||||
$namespace = static::getCurrentNamespace();
|
||||
|
||||
if (! $namespace) {
|
||||
return $query->whereRaw('1 = 0'); // Return empty result
|
||||
}
|
||||
|
||||
return $query->where('namespace_id', $namespace->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope query to a specific namespace.
|
||||
*/
|
||||
public function scopeForNamespace(Builder $query, Namespace_|int $namespace): Builder
|
||||
{
|
||||
$namespaceId = $namespace instanceof Namespace_ ? $namespace->id : $namespace;
|
||||
|
||||
return $query->where('namespace_id', $namespaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope query to all namespaces accessible by the current user.
|
||||
*/
|
||||
public function scopeAccessibleByCurrentUser(Builder $query): Builder
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user || ! $user instanceof User) {
|
||||
return $query->whereRaw('1 = 0'); // Return empty result
|
||||
}
|
||||
|
||||
$namespaceIds = Namespace_::accessibleBy($user)->pluck('id');
|
||||
|
||||
return $query->whereIn('namespace_id', $namespaceIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all models owned by the current namespace, cached.
|
||||
*
|
||||
* @param int $ttl Cache TTL in seconds (default 5 minutes)
|
||||
*/
|
||||
public static function ownedByCurrentNamespaceCached(int $ttl = 300): Collection
|
||||
{
|
||||
$namespace = static::getCurrentNamespace();
|
||||
|
||||
if (! $namespace) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return Cache::remember(
|
||||
static::namespaceCacheKey($namespace->id),
|
||||
$ttl,
|
||||
fn () => static::ownedByCurrentNamespace()->get()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all models for a specific namespace, cached.
|
||||
*
|
||||
* @param int $ttl Cache TTL in seconds (default 5 minutes)
|
||||
*/
|
||||
public static function forNamespaceCached(Namespace_|int $namespace, int $ttl = 300): Collection
|
||||
{
|
||||
$namespaceId = $namespace instanceof Namespace_ ? $namespace->id : $namespace;
|
||||
|
||||
return Cache::remember(
|
||||
static::namespaceCacheKey($namespaceId),
|
||||
$ttl,
|
||||
fn () => static::forNamespace($namespaceId)->get()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cache key for a namespace's model collection.
|
||||
*/
|
||||
protected static function namespaceCacheKey(int $namespaceId): string
|
||||
{
|
||||
$modelClass = class_basename(static::class);
|
||||
|
||||
return "namespace.{$namespaceId}.{$modelClass}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cache for a namespace's model collection.
|
||||
*/
|
||||
public static function clearNamespaceCache(int $namespaceId): void
|
||||
{
|
||||
Cache::forget(static::namespaceCacheKey($namespaceId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache for all namespaces accessible to current user.
|
||||
*/
|
||||
public static function clearAllNamespaceCache(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if ($user && $user instanceof User) {
|
||||
$namespaces = Namespace_::accessibleBy($user)->get();
|
||||
foreach ($namespaces as $namespace) {
|
||||
static::clearNamespaceCache($namespace->id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current namespace from session/request.
|
||||
*/
|
||||
protected static function getCurrentNamespace(): ?Namespace_
|
||||
{
|
||||
// Try to get from request attributes (set by middleware)
|
||||
if (request()->attributes->has('current_namespace')) {
|
||||
return request()->attributes->get('current_namespace');
|
||||
}
|
||||
|
||||
// Try to get from session
|
||||
$namespaceUuid = session('current_namespace_uuid');
|
||||
if ($namespaceUuid) {
|
||||
$namespace = Namespace_::where('uuid', $namespaceUuid)->first();
|
||||
if ($namespace) {
|
||||
return $namespace;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to user's default namespace
|
||||
$user = auth()->user();
|
||||
if ($user && method_exists($user, 'defaultNamespace')) {
|
||||
return $user->defaultNamespace();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this model belongs to the given namespace.
|
||||
*/
|
||||
public function belongsToNamespace(Namespace_|int $namespace): bool
|
||||
{
|
||||
$namespaceId = $namespace instanceof Namespace_ ? $namespace->id : $namespace;
|
||||
|
||||
return $this->namespace_id === $namespaceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this model belongs to the current namespace.
|
||||
*/
|
||||
public function belongsToCurrentNamespace(): bool
|
||||
{
|
||||
$namespace = static::getCurrentNamespace();
|
||||
|
||||
if (! $namespace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->belongsToNamespace($namespace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user can access this model.
|
||||
*/
|
||||
public function isAccessibleByCurrentUser(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->namespace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->namespace->isAccessibleBy($user);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,15 +2,15 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Api\Controllers;
|
||||
namespace Mod\Api\Controllers;
|
||||
|
||||
use Core\Front\Controller;
|
||||
use Core\Mod\Tenant\Models\EntitlementLog;
|
||||
use Core\Mod\Tenant\Models\Package;
|
||||
use Core\Mod\Tenant\Models\WorkspacePackage;
|
||||
use Core\Mod\Tenant\Models\User;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
use Core\Mod\Tenant\Services\EntitlementService;
|
||||
use Mod\Tenant\Models\EntitlementLog;
|
||||
use Mod\Tenant\Models\Package;
|
||||
use Mod\Tenant\Models\WorkspacePackage;
|
||||
use Mod\Tenant\Models\User;
|
||||
use Mod\Tenant\Models\Workspace;
|
||||
use Mod\Tenant\Services\EntitlementService;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
@ -2,17 +2,17 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Api\Controllers;
|
||||
namespace Mod\Api\Controllers;
|
||||
|
||||
use Core\Front\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Core\Mod\Api\Controllers\Concerns\HasApiResponses;
|
||||
use Core\Mod\Api\Controllers\Concerns\ResolvesWorkspace;
|
||||
use Core\Mod\Api\Resources\PaginatedCollection;
|
||||
use Core\Mod\Api\Resources\WorkspaceResource;
|
||||
use Core\Mod\Tenant\Models\User;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
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.
|
||||
|
|
@ -250,18 +250,25 @@ class WorkspaceController extends Controller
|
|||
return $this->notFoundResponse('Workspace');
|
||||
}
|
||||
|
||||
// Clear existing defaults
|
||||
$user->workspaces()
|
||||
->where('domain', 'hub.host.uk.com')
|
||||
->updateExistingPivot(
|
||||
$user->workspaces()->pluck('workspaces.id')->toArray(),
|
||||
['is_default' => false]
|
||||
);
|
||||
// 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 new default
|
||||
$user->workspaces()->updateExistingPivot($workspace->id, [
|
||||
'is_default' => true,
|
||||
]);
|
||||
// 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']);
|
||||
|
||||
|
|
@ -1,388 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Core\Mod\Tenant\Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Core\Mod\Analytics\Models\Website as AnalyticsWebsite;
|
||||
use Core\Mod\Notify\Models\PushWebsite;
|
||||
use Core\Mod\Support\Models\Mailbox;
|
||||
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 Core\Mod\Trust\Models\Campaign as TrustCampaign;
|
||||
use Core\Mod\Web\Models\Block;
|
||||
use Core\Mod\Web\Models\Page as BioPage;
|
||||
|
||||
class SystemUserSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Seed the system user (user ID 1) with full Hades access.
|
||||
* This ensures platform owner always has access to everything.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
if (! Schema::hasTable('users')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get or create user 1 (may already exist from WorkspaceSeeder)
|
||||
$user = User::updateOrCreate(
|
||||
['id' => 1],
|
||||
[
|
||||
'name' => 'Snider',
|
||||
'email' => 'snider@host.uk.com',
|
||||
'password' => Hash::make('change-me-in-env'),
|
||||
'tier' => UserTier::HADES,
|
||||
'tier_expires_at' => null, // Never expires
|
||||
'email_verified_at' => now(),
|
||||
]
|
||||
);
|
||||
|
||||
// Environment-aware domain
|
||||
$isLocal = app()->environment('local');
|
||||
$host = $isLocal ? 'host.test' : 'host.uk.com';
|
||||
|
||||
// Create default workspace if none exists
|
||||
$workspace = Workspace::firstOrCreate(
|
||||
['slug' => 'system'],
|
||||
[
|
||||
'name' => 'System',
|
||||
'domain' => $host,
|
||||
'icon' => 'shield-check',
|
||||
'color' => 'red',
|
||||
'description' => 'System workspace for platform administration',
|
||||
'type' => 'custom',
|
||||
'is_active' => true,
|
||||
'sort_order' => 0,
|
||||
]
|
||||
);
|
||||
|
||||
// Attach user to workspace as owner (or update if exists)
|
||||
if ($workspace->users()->where('user_id', $user->id)->exists()) {
|
||||
// Update existing pivot to ensure system is default
|
||||
$workspace->users()->updateExistingPivot($user->id, [
|
||||
'role' => 'owner',
|
||||
'is_default' => true,
|
||||
]);
|
||||
// Clear other defaults for this user
|
||||
\Illuminate\Support\Facades\DB::table('user_workspace')
|
||||
->where('user_id', $user->id)
|
||||
->where('workspace_id', '!=', $workspace->id)
|
||||
->update(['is_default' => false]);
|
||||
} else {
|
||||
$workspace->users()->attach($user->id, [
|
||||
'role' => 'owner',
|
||||
'is_default' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
// Assign hermes package (founding patron - unlimited everything)
|
||||
$hermesPackage = Package::where('code', 'hermes')->first();
|
||||
if ($hermesPackage) {
|
||||
WorkspacePackage::updateOrCreate(
|
||||
[
|
||||
'workspace_id' => $workspace->id,
|
||||
'package_id' => $hermesPackage->id,
|
||||
],
|
||||
[
|
||||
'status' => WorkspacePackage::STATUS_ACTIVE,
|
||||
'starts_at' => now(),
|
||||
'expires_at' => null, // Never expires
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Assign hades package (developer tools)
|
||||
$hadesPackage = Package::where('code', 'hades')->first();
|
||||
if ($hadesPackage) {
|
||||
WorkspacePackage::updateOrCreate(
|
||||
[
|
||||
'workspace_id' => $workspace->id,
|
||||
'package_id' => $hadesPackage->id,
|
||||
],
|
||||
[
|
||||
'status' => WorkspacePackage::STATUS_ACTIVE,
|
||||
'starts_at' => now(),
|
||||
'expires_at' => null, // Never expires
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Assign apollo package (beta features)
|
||||
$apolloPackage = Package::where('code', 'apollo')->first();
|
||||
if ($apolloPackage) {
|
||||
WorkspacePackage::updateOrCreate(
|
||||
[
|
||||
'workspace_id' => $workspace->id,
|
||||
'package_id' => $apolloPackage->id,
|
||||
],
|
||||
[
|
||||
'status' => WorkspacePackage::STATUS_ACTIVE,
|
||||
'starts_at' => now(),
|
||||
'expires_at' => null, // Never expires
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$this->command->info("System user created: {$user->email} (ID: {$user->id})");
|
||||
$this->command->info("System workspace: {$workspace->name} with Hermes + Hades + Apollo packages");
|
||||
|
||||
// Set up services for host.uk.com
|
||||
$this->setupServices($workspace, $user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up all services for the System workspace.
|
||||
*/
|
||||
protected function setupServices(Workspace $workspace, User $user): void
|
||||
{
|
||||
// Environment-aware domains: .test for local, .uk.com for production
|
||||
$isLocal = app()->environment('local');
|
||||
$host = $isLocal ? 'host.test' : 'host.uk.com';
|
||||
$email = $isLocal ? 'support@host.test' : 'support@host.uk.com';
|
||||
|
||||
// Analytics - website tracking
|
||||
$analyticsWebsite = AnalyticsWebsite::updateOrCreate(
|
||||
[
|
||||
'workspace_id' => $workspace->id,
|
||||
'host' => $host,
|
||||
],
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'name' => 'Host UK',
|
||||
'url' => "https://{$host}",
|
||||
'pixel_key' => Str::uuid()->toString(),
|
||||
'tracking_enabled' => true,
|
||||
'is_enabled' => true,
|
||||
'track_pageviews' => true,
|
||||
'track_sessions' => true,
|
||||
'track_goals' => true,
|
||||
]
|
||||
);
|
||||
$this->command->info("Analytics: {$analyticsWebsite->name} ({$analyticsWebsite->host})");
|
||||
|
||||
// Trust - reviews campaign
|
||||
$trustCampaign = TrustCampaign::updateOrCreate(
|
||||
[
|
||||
'workspace_id' => $workspace->id,
|
||||
'host' => $host,
|
||||
],
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'name' => 'Host UK Reviews',
|
||||
'pixel_key' => Str::uuid()->toString(),
|
||||
'is_enabled' => true,
|
||||
'primary_color' => '#8b5cf6', // Violet to match brand
|
||||
]
|
||||
);
|
||||
$this->command->info("Trust: {$trustCampaign->name} ({$trustCampaign->host})");
|
||||
|
||||
// Notify - push notifications
|
||||
$vapidKeys = PushWebsite::generateVapidKeys();
|
||||
$pushWebsite = PushWebsite::updateOrCreate(
|
||||
[
|
||||
'workspace_id' => $workspace->id,
|
||||
'host' => $host,
|
||||
],
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'name' => 'Host UK',
|
||||
'pixel_key' => Str::uuid()->toString(),
|
||||
'vapid_public_key' => $vapidKeys['public'],
|
||||
'vapid_private_key' => $vapidKeys['private'],
|
||||
'is_enabled' => true,
|
||||
'widget_settings' => array_merge(
|
||||
PushWebsite::defaultWidgetSettings(),
|
||||
[
|
||||
'primary_color' => '#8b5cf6',
|
||||
'prompt_title' => 'Stay in the loop',
|
||||
'prompt_message' => 'Get notified about new features and updates.',
|
||||
]
|
||||
),
|
||||
]
|
||||
);
|
||||
$this->command->info("Notify: {$pushWebsite->name} ({$pushWebsite->host})");
|
||||
|
||||
// Support - mailbox
|
||||
$mailbox = Mailbox::updateOrCreate(
|
||||
[
|
||||
'workspace_id' => $workspace->id,
|
||||
'email' => $email,
|
||||
],
|
||||
[
|
||||
'name' => 'Support',
|
||||
'slug' => 'support',
|
||||
'signature' => "Best regards,\nThe Host UK Team",
|
||||
'auto_reply_enabled' => false,
|
||||
]
|
||||
);
|
||||
$this->command->info("Support: {$mailbox->name} ({$mailbox->email})");
|
||||
|
||||
// Bio - link in bio page
|
||||
$this->setupBioPage($workspace, $user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the Host UK bio page.
|
||||
*/
|
||||
protected function setupBioPage(Workspace $workspace, User $user): void
|
||||
{
|
||||
// Environment-aware domains
|
||||
$isLocal = app()->environment('local');
|
||||
$host = $isLocal ? 'host.test' : 'host.uk.com';
|
||||
$helpHost = $isLocal ? 'help.host.test' : 'help.host.uk.com';
|
||||
|
||||
// Create the main bio page
|
||||
$bioPage = BioPage::updateOrCreate(
|
||||
[
|
||||
'workspace_id' => $workspace->id,
|
||||
'url' => 'hostuk',
|
||||
],
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'type' => 'biolink',
|
||||
'is_enabled' => true,
|
||||
'settings' => [
|
||||
'seo' => [
|
||||
'title' => 'Host UK - Creator Hosting Tools',
|
||||
'description' => 'Premium hosting tools for UK creators. Bio pages, social scheduling, privacy-first analytics, and more.',
|
||||
],
|
||||
'theme' => [
|
||||
'background' => [
|
||||
'type' => 'gradient',
|
||||
'gradient_start' => '#1e1b4b',
|
||||
'gradient_end' => '#312e81',
|
||||
'gradient_direction' => '180',
|
||||
],
|
||||
'text_color' => '#ffffff',
|
||||
'font_family' => 'Inter',
|
||||
'button' => [
|
||||
'background_color' => '#8b5cf6',
|
||||
'text_color' => '#ffffff',
|
||||
'border_radius' => '12px',
|
||||
'border_width' => '0',
|
||||
],
|
||||
],
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
// Create redirect from /host to /hostuk
|
||||
BioPage::updateOrCreate(
|
||||
[
|
||||
'workspace_id' => $workspace->id,
|
||||
'url' => 'host',
|
||||
],
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'type' => 'link',
|
||||
'location_url' => '/hostuk',
|
||||
'is_enabled' => true,
|
||||
]
|
||||
);
|
||||
|
||||
// Set up blocks (delete existing and recreate for clean state)
|
||||
$bioPage->blocks()->delete();
|
||||
|
||||
$blocks = [
|
||||
[
|
||||
'type' => 'avatar',
|
||||
'order' => 1,
|
||||
'settings' => [
|
||||
'image' => '/images/host-uk-logo-icon.png',
|
||||
'size' => 'large',
|
||||
'shape' => 'rounded',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'heading',
|
||||
'order' => 2,
|
||||
'settings' => [
|
||||
'text' => 'Host UK',
|
||||
'size' => 'large',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'paragraph',
|
||||
'order' => 3,
|
||||
'settings' => [
|
||||
'text' => 'Premium hosting tools for UK creators and businesses. EU-hosted, GDPR compliant.',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'socials',
|
||||
'order' => 4,
|
||||
'settings' => [
|
||||
'style' => 'rounded',
|
||||
'size' => 'medium',
|
||||
'socials' => [
|
||||
['platform' => 'x', 'url' => 'https://x.com/hostukcom'],
|
||||
['platform' => 'github', 'url' => 'https://github.com/nicsnide'],
|
||||
['platform' => 'linkedin', 'url' => 'https://linkedin.com/company/hostukcom'],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'divider',
|
||||
'order' => 5,
|
||||
'settings' => ['style' => 'line'],
|
||||
],
|
||||
[
|
||||
'type' => 'link',
|
||||
'order' => 6,
|
||||
'location_url' => "https://{$host}",
|
||||
'settings' => [
|
||||
'name' => 'Visit Host UK',
|
||||
'icon' => 'globe',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'link',
|
||||
'order' => 7,
|
||||
'location_url' => "https://{$host}/pricing",
|
||||
'settings' => [
|
||||
'name' => 'View Pricing',
|
||||
'icon' => 'tag',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'link',
|
||||
'order' => 8,
|
||||
'location_url' => "https://{$helpHost}",
|
||||
'settings' => [
|
||||
'name' => 'Help Centre',
|
||||
'icon' => 'book-open',
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'link',
|
||||
'order' => 9,
|
||||
'location_url' => "mailto:hello@{$host}",
|
||||
'settings' => [
|
||||
'name' => 'Get in Touch',
|
||||
'icon' => 'envelope',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($blocks as $blockData) {
|
||||
Block::create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'biolink_id' => $bioPage->id,
|
||||
'type' => $blockData['type'],
|
||||
'order' => $blockData['order'],
|
||||
'location_url' => $blockData['location_url'] ?? null,
|
||||
'settings' => $blockData['settings'],
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->command->info("Bio: /{$bioPage->url} (with ".count($blocks).' blocks)');
|
||||
$this->command->info('Bio: /host -> /hostuk (redirect)');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,316 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Core tenant tables - users, workspaces, namespaces, entitlements.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::disableForeignKeyConstraints();
|
||||
|
||||
// 1. Users
|
||||
Schema::create('users', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('email')->unique();
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password');
|
||||
$table->rememberToken();
|
||||
$table->string('tier')->default('free');
|
||||
$table->timestamp('tier_expires_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
// 2. Password Reset Tokens
|
||||
Schema::create('password_reset_tokens', function (Blueprint $table) {
|
||||
$table->string('email')->primary();
|
||||
$table->string('token');
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
// 3. Sessions
|
||||
Schema::create('sessions', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->foreignId('user_id')->nullable()->index();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->longText('payload');
|
||||
$table->integer('last_activity')->index();
|
||||
});
|
||||
|
||||
// 4. Workspaces (the tenant boundary)
|
||||
Schema::create('workspaces', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('slug')->unique();
|
||||
$table->string('domain')->nullable();
|
||||
$table->string('icon')->nullable();
|
||||
$table->string('color')->nullable();
|
||||
$table->text('description')->nullable();
|
||||
$table->string('type')->default('default');
|
||||
$table->json('settings')->nullable();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->integer('sort_order')->default(0);
|
||||
|
||||
// WP Connector fields
|
||||
$table->boolean('wp_connector_enabled')->default(false);
|
||||
$table->string('wp_connector_url')->nullable();
|
||||
$table->string('wp_connector_secret')->nullable();
|
||||
$table->timestamp('wp_connector_verified_at')->nullable();
|
||||
$table->timestamp('wp_connector_last_sync')->nullable();
|
||||
$table->json('wp_connector_config')->nullable();
|
||||
|
||||
// Billing fields
|
||||
$table->string('stripe_customer_id')->nullable();
|
||||
$table->string('btcpay_customer_id')->nullable();
|
||||
$table->string('billing_name')->nullable();
|
||||
$table->string('billing_email')->nullable();
|
||||
$table->string('billing_address_line1')->nullable();
|
||||
$table->string('billing_address_line2')->nullable();
|
||||
$table->string('billing_city')->nullable();
|
||||
$table->string('billing_state')->nullable();
|
||||
$table->string('billing_postal_code')->nullable();
|
||||
$table->string('billing_country')->nullable();
|
||||
$table->string('vat_number')->nullable();
|
||||
$table->string('tax_id')->nullable();
|
||||
$table->boolean('tax_exempt')->default(false);
|
||||
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
|
||||
// 5. User Workspace Pivot
|
||||
Schema::create('user_workspace', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('role')->default('member');
|
||||
$table->boolean('is_default')->default(false);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['user_id', 'workspace_id']);
|
||||
});
|
||||
|
||||
// 6. Namespaces
|
||||
Schema::create('namespaces', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid('uuid')->unique();
|
||||
$table->string('name', 128);
|
||||
$table->string('slug', 64);
|
||||
$table->string('description', 512)->nullable();
|
||||
$table->string('icon', 64)->default('folder');
|
||||
$table->string('color', 16)->default('zinc');
|
||||
|
||||
// Polymorphic owner (User::class or Workspace::class)
|
||||
$table->morphs('owner');
|
||||
|
||||
// Workspace context for billing aggregation
|
||||
$table->foreignId('workspace_id')->nullable()
|
||||
->constrained()->nullOnDelete();
|
||||
|
||||
$table->json('settings')->nullable();
|
||||
$table->boolean('is_default')->default(false);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->smallInteger('sort_order')->default(0);
|
||||
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->unique(['owner_type', 'owner_id', 'slug']);
|
||||
$table->index(['workspace_id', 'is_active']);
|
||||
$table->index(['owner_type', 'owner_id', 'is_active']);
|
||||
});
|
||||
|
||||
// 7. Entitlement Features
|
||||
Schema::create('entitlement_features', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('code')->unique();
|
||||
$table->string('name');
|
||||
$table->text('description')->nullable();
|
||||
$table->string('category')->nullable();
|
||||
$table->enum('type', ['boolean', 'limit', 'unlimited'])->default('boolean');
|
||||
$table->enum('reset_type', ['none', 'monthly', 'rolling'])->default('none');
|
||||
$table->integer('rolling_window_days')->nullable();
|
||||
$table->foreignId('parent_feature_id')->nullable()
|
||||
->constrained('entitlement_features')->nullOnDelete();
|
||||
$table->integer('sort_order')->default(0);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['category', 'sort_order']);
|
||||
$table->index('category');
|
||||
});
|
||||
|
||||
// 8. Entitlement Packages
|
||||
Schema::create('entitlement_packages', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('code')->unique();
|
||||
$table->string('name');
|
||||
$table->text('description')->nullable();
|
||||
$table->string('icon')->nullable();
|
||||
$table->string('color')->nullable();
|
||||
$table->integer('sort_order')->default(0);
|
||||
$table->boolean('is_stackable')->default(true);
|
||||
$table->boolean('is_base_package')->default(false);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->boolean('is_public')->default(true);
|
||||
$table->decimal('monthly_price', 10, 2)->nullable();
|
||||
$table->decimal('yearly_price', 10, 2)->nullable();
|
||||
$table->decimal('setup_fee', 10, 2)->default(0);
|
||||
$table->unsignedInteger('trial_days')->default(0);
|
||||
$table->string('stripe_monthly_price_id')->nullable();
|
||||
$table->string('stripe_yearly_price_id')->nullable();
|
||||
$table->string('btcpay_monthly_price_id')->nullable();
|
||||
$table->string('btcpay_yearly_price_id')->nullable();
|
||||
$table->string('blesta_package_id')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index('blesta_package_id');
|
||||
});
|
||||
|
||||
// 9. Entitlement Package Features
|
||||
Schema::create('entitlement_package_features', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('package_id')->constrained('entitlement_packages')->cascadeOnDelete();
|
||||
$table->foreignId('feature_id')->constrained('entitlement_features')->cascadeOnDelete();
|
||||
$table->unsignedBigInteger('limit_value')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['package_id', 'feature_id']);
|
||||
});
|
||||
|
||||
// 10. Entitlement Workspace Packages
|
||||
Schema::create('entitlement_workspace_packages', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('package_id')->constrained('entitlement_packages')->cascadeOnDelete();
|
||||
$table->enum('status', ['active', 'suspended', 'cancelled', 'expired'])->default('active');
|
||||
$table->timestamp('starts_at')->nullable();
|
||||
$table->timestamp('expires_at')->nullable();
|
||||
$table->timestamp('billing_cycle_anchor')->nullable();
|
||||
$table->string('blesta_service_id')->nullable();
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['workspace_id', 'status'], 'ent_ws_pkg_ws_status_idx');
|
||||
$table->index(['expires_at', 'status'], 'ent_ws_pkg_expires_status_idx');
|
||||
$table->index('blesta_service_id');
|
||||
});
|
||||
|
||||
// 11. Entitlement Namespace Packages
|
||||
Schema::create('entitlement_namespace_packages', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('namespace_id')->constrained('namespaces')->cascadeOnDelete();
|
||||
$table->foreignId('package_id')->constrained('entitlement_packages')->cascadeOnDelete();
|
||||
$table->enum('status', ['active', 'suspended', 'cancelled', 'expired'])->default('active');
|
||||
$table->timestamp('starts_at')->nullable();
|
||||
$table->timestamp('expires_at')->nullable();
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['namespace_id', 'status']);
|
||||
$table->index(['expires_at', 'status']);
|
||||
});
|
||||
|
||||
// 12. Entitlement Boosts
|
||||
Schema::create('entitlement_boosts', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('feature_code');
|
||||
$table->enum('boost_type', ['add_limit', 'enable', 'unlimited'])->default('add_limit');
|
||||
$table->enum('duration_type', ['cycle_bound', 'duration', 'permanent'])->default('cycle_bound');
|
||||
$table->unsignedBigInteger('limit_value')->nullable();
|
||||
$table->unsignedBigInteger('consumed_quantity')->default(0);
|
||||
$table->enum('status', ['active', 'exhausted', 'expired', 'cancelled'])->default('active');
|
||||
$table->timestamp('starts_at')->nullable();
|
||||
$table->timestamp('expires_at')->nullable();
|
||||
$table->string('blesta_addon_id')->nullable();
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['workspace_id', 'feature_code', 'status'], 'ent_boosts_ws_feat_status_idx');
|
||||
$table->index(['expires_at', 'status'], 'ent_boosts_expires_status_idx');
|
||||
$table->index('feature_code');
|
||||
$table->index('blesta_addon_id');
|
||||
});
|
||||
|
||||
// 13. Entitlement Usage Records
|
||||
Schema::create('entitlement_usage_records', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('feature_code');
|
||||
$table->unsignedBigInteger('quantity')->default(1);
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamp('recorded_at');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['workspace_id', 'feature_code', 'recorded_at'], 'ent_usage_ws_feat_rec_idx');
|
||||
$table->index('recorded_at', 'ent_usage_recorded_idx');
|
||||
$table->index('feature_code');
|
||||
});
|
||||
|
||||
// 14. Entitlement Logs
|
||||
Schema::create('entitlement_logs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('action');
|
||||
$table->string('entity_type');
|
||||
$table->unsignedBigInteger('entity_id')->nullable();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('source')->nullable();
|
||||
$table->json('old_values')->nullable();
|
||||
$table->json('new_values')->nullable();
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['workspace_id', 'action'], 'ent_logs_ws_action_idx');
|
||||
$table->index(['entity_type', 'entity_id'], 'ent_logs_entity_idx');
|
||||
$table->index('created_at', 'ent_logs_created_idx');
|
||||
});
|
||||
|
||||
// 15. User Two-Factor Auth
|
||||
Schema::create('user_two_factor_auth', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete();
|
||||
$table->text('secret')->nullable();
|
||||
$table->json('recovery_codes')->nullable();
|
||||
$table->timestamp('confirmed_at')->nullable();
|
||||
$table->timestamp('enabled_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::enableForeignKeyConstraints();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::disableForeignKeyConstraints();
|
||||
Schema::dropIfExists('user_two_factor_auth');
|
||||
Schema::dropIfExists('entitlement_logs');
|
||||
Schema::dropIfExists('entitlement_usage_records');
|
||||
Schema::dropIfExists('entitlement_boosts');
|
||||
Schema::dropIfExists('entitlement_namespace_packages');
|
||||
Schema::dropIfExists('entitlement_workspace_packages');
|
||||
Schema::dropIfExists('entitlement_package_features');
|
||||
Schema::dropIfExists('entitlement_packages');
|
||||
Schema::dropIfExists('entitlement_features');
|
||||
Schema::dropIfExists('namespaces');
|
||||
Schema::dropIfExists('user_workspace');
|
||||
Schema::dropIfExists('workspaces');
|
||||
Schema::dropIfExists('sessions');
|
||||
Schema::dropIfExists('password_reset_tokens');
|
||||
Schema::dropIfExists('users');
|
||||
Schema::enableForeignKeyConstraints();
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
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 (optional)
|
||||
// User-owned namespaces can have a workspace for billing
|
||||
$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();
|
||||
|
||||
// Each owner can only have one namespace with a given slug
|
||||
$table->unique(['owner_type', 'owner_id', 'slug']);
|
||||
$table->index(['workspace_id', 'is_active']);
|
||||
$table->index(['owner_type', 'owner_id', 'is_active']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('namespaces');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
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->string('status', 20)->default('active');
|
||||
$table->timestamp('starts_at')->nullable();
|
||||
$table->timestamp('expires_at')->nullable();
|
||||
$table->timestamp('billing_cycle_anchor')->nullable();
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['namespace_id', 'status']);
|
||||
$table->index(['package_id', 'status']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('entitlement_namespace_packages');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Add namespace_id to entitlement_boosts
|
||||
Schema::table('entitlement_boosts', function (Blueprint $table) {
|
||||
$table->foreignId('namespace_id')->nullable()
|
||||
->after('workspace_id')
|
||||
->constrained('namespaces')
|
||||
->nullOnDelete();
|
||||
|
||||
$table->index(['namespace_id', 'feature_code', 'status']);
|
||||
});
|
||||
|
||||
// Add namespace_id to entitlement_usage_records
|
||||
Schema::table('entitlement_usage_records', function (Blueprint $table) {
|
||||
$table->foreignId('namespace_id')->nullable()
|
||||
->after('workspace_id')
|
||||
->constrained('namespaces')
|
||||
->nullOnDelete();
|
||||
|
||||
$table->index(['namespace_id', 'feature_code', 'recorded_at']);
|
||||
});
|
||||
|
||||
// Add namespace_id to entitlement_logs
|
||||
Schema::table('entitlement_logs', function (Blueprint $table) {
|
||||
$table->foreignId('namespace_id')->nullable()
|
||||
->after('workspace_id')
|
||||
->constrained('namespaces')
|
||||
->nullOnDelete();
|
||||
|
||||
$table->index(['namespace_id', 'action', 'created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('entitlement_boosts', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('namespace_id');
|
||||
});
|
||||
|
||||
Schema::table('entitlement_usage_records', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('namespace_id');
|
||||
});
|
||||
|
||||
Schema::table('entitlement_logs', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('namespace_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
176
packages/core-php/src/Mod/Tenant/Models/NamespacePackage.php
Normal file
176
packages/core-php/src/Mod/Tenant/Models/NamespacePackage.php
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Package assignment to a namespace for entitlement tracking.
|
||||
*
|
||||
* Namespace-level packages allow for granular entitlement control
|
||||
* separate from workspace-level packages. The entitlement cascade is:
|
||||
* 1. Namespace packages (checked first)
|
||||
* 2. Workspace packages (fallback if namespace has workspace context)
|
||||
* 3. User tier (final fallback for user-owned namespaces)
|
||||
*/
|
||||
class NamespacePackage extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'entitlement_namespace_packages';
|
||||
|
||||
protected $fillable = [
|
||||
'namespace_id',
|
||||
'package_id',
|
||||
'status',
|
||||
'starts_at',
|
||||
'expires_at',
|
||||
'billing_cycle_anchor',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'starts_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'billing_cycle_anchor' => 'datetime',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
* Status constants.
|
||||
*/
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
|
||||
public const STATUS_SUSPENDED = 'suspended';
|
||||
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
|
||||
/**
|
||||
* The namespace this package belongs to.
|
||||
*/
|
||||
public function namespace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Namespace_::class, 'namespace_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* The package definition.
|
||||
*/
|
||||
public function package(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Package::class, 'package_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to active assignments.
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_ACTIVE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to non-expired assignments.
|
||||
*/
|
||||
public function scopeNotExpired($query)
|
||||
{
|
||||
return $query->where(function ($q) {
|
||||
$q->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this assignment is currently active.
|
||||
*/
|
||||
public function isActive(): bool
|
||||
{
|
||||
if ($this->status !== self::STATUS_ACTIVE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->starts_at && $this->starts_at->isFuture()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->expires_at && $this->expires_at->isPast()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this assignment is on grace period.
|
||||
*/
|
||||
public function onGracePeriod(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_CANCELLED
|
||||
&& $this->expires_at
|
||||
&& $this->expires_at->isFuture();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current billing cycle start date.
|
||||
*/
|
||||
public function getCurrentCycleStart(): Carbon
|
||||
{
|
||||
if (! $this->billing_cycle_anchor) {
|
||||
return $this->starts_at ?? $this->created_at;
|
||||
}
|
||||
|
||||
$anchor = $this->billing_cycle_anchor->copy();
|
||||
$now = now();
|
||||
|
||||
// Find the most recent cycle start
|
||||
while ($anchor->addMonth()->lte($now)) {
|
||||
// Keep advancing until we pass now
|
||||
}
|
||||
|
||||
return $anchor->subMonth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current billing cycle end date.
|
||||
*/
|
||||
public function getCurrentCycleEnd(): Carbon
|
||||
{
|
||||
return $this->getCurrentCycleStart()->copy()->addMonth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspend this assignment.
|
||||
*/
|
||||
public function suspend(): void
|
||||
{
|
||||
$this->update(['status' => self::STATUS_SUSPENDED]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactivate this assignment.
|
||||
*/
|
||||
public function reactivate(): void
|
||||
{
|
||||
$this->update(['status' => self::STATUS_ACTIVE]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel this assignment.
|
||||
*/
|
||||
public function cancel(?Carbon $endsAt = null): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => self::STATUS_CANCELLED,
|
||||
'expires_at' => $endsAt ?? $this->getCurrentCycleEnd(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
321
packages/core-php/src/Mod/Tenant/Models/Namespace_.php
Normal file
321
packages/core-php/src/Mod/Tenant/Models/Namespace_.php
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Namespace model - universal tenant boundary for products.
|
||||
*
|
||||
* A namespace provides a clean ownership boundary where products belong to
|
||||
* a namespace rather than directly to User/Workspace. The namespace itself
|
||||
* has polymorphic ownership (User or Workspace can own).
|
||||
*
|
||||
* Ownership patterns:
|
||||
* - Individual user: User → Namespace → Products
|
||||
* - Agency: Workspace → Namespace(s) → Products (one per client)
|
||||
* - Team member: User in Workspace → access to Workspace's Namespaces
|
||||
*/
|
||||
class Namespace_ extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*/
|
||||
protected $table = 'namespaces';
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*/
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'name',
|
||||
'slug',
|
||||
'description',
|
||||
'icon',
|
||||
'color',
|
||||
'owner_type',
|
||||
'owner_id',
|
||||
'workspace_id',
|
||||
'settings',
|
||||
'is_default',
|
||||
'is_active',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*/
|
||||
protected $casts = [
|
||||
'settings' => 'array',
|
||||
'is_default' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* Boot the model.
|
||||
*/
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (self $namespace) {
|
||||
if (empty($namespace->uuid)) {
|
||||
$namespace->uuid = (string) Str::uuid();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Ownership Relationships
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the owner of the namespace (User or Workspace).
|
||||
*/
|
||||
public function owner(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the workspace for billing aggregation (if set).
|
||||
*
|
||||
* This is separate from owner - a user-owned namespace can still
|
||||
* have a workspace context for billing purposes.
|
||||
*/
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this namespace is owned by a user.
|
||||
*/
|
||||
public function isOwnedByUser(): bool
|
||||
{
|
||||
return $this->owner_type === User::class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this namespace is owned by a workspace.
|
||||
*/
|
||||
public function isOwnedByWorkspace(): bool
|
||||
{
|
||||
return $this->owner_type === Workspace::class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the owner as User (or null if workspace-owned).
|
||||
*/
|
||||
public function getOwnerUser(): ?User
|
||||
{
|
||||
if ($this->isOwnedByUser()) {
|
||||
return $this->owner;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the owner as Workspace (or null if user-owned).
|
||||
*/
|
||||
public function getOwnerWorkspace(): ?Workspace
|
||||
{
|
||||
if ($this->isOwnedByWorkspace()) {
|
||||
return $this->owner;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Entitlement Relationships
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Active package assignments for this namespace.
|
||||
*/
|
||||
public function namespacePackages(): HasMany
|
||||
{
|
||||
return $this->hasMany(NamespacePackage::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Active boosts for this namespace.
|
||||
*/
|
||||
public function boosts(): HasMany
|
||||
{
|
||||
return $this->hasMany(Boost::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage records for this namespace.
|
||||
*/
|
||||
public function usageRecords(): HasMany
|
||||
{
|
||||
return $this->hasMany(UsageRecord::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entitlement logs for this namespace.
|
||||
*/
|
||||
public function entitlementLogs(): HasMany
|
||||
{
|
||||
return $this->hasMany(EntitlementLog::class);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Settings & Configuration
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get a setting value from the settings JSON column.
|
||||
*/
|
||||
public function getSetting(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return data_get($this->settings, $key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a setting value in the settings JSON column.
|
||||
*/
|
||||
public function setSetting(string $key, mixed $value): self
|
||||
{
|
||||
$settings = $this->settings ?? [];
|
||||
data_set($settings, $key, $value);
|
||||
$this->settings = $settings;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Scopes
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scope to only active namespaces.
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to order by sort order.
|
||||
*/
|
||||
public function scopeOrdered($query)
|
||||
{
|
||||
return $query->orderBy('sort_order');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to namespaces owned by a specific user.
|
||||
*/
|
||||
public function scopeOwnedByUser($query, User|int $user)
|
||||
{
|
||||
$userId = $user instanceof User ? $user->id : $user;
|
||||
|
||||
return $query->where('owner_type', User::class)
|
||||
->where('owner_id', $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to namespaces owned by a specific workspace.
|
||||
*/
|
||||
public function scopeOwnedByWorkspace($query, Workspace|int $workspace)
|
||||
{
|
||||
$workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace;
|
||||
|
||||
return $query->where('owner_type', Workspace::class)
|
||||
->where('owner_id', $workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to namespaces accessible by a user (owned by user OR owned by user's workspaces).
|
||||
*/
|
||||
public function scopeAccessibleBy($query, User $user)
|
||||
{
|
||||
$workspaceIds = $user->workspaces()->pluck('workspaces.id');
|
||||
|
||||
return $query->where(function ($q) use ($user, $workspaceIds) {
|
||||
// User-owned namespaces
|
||||
$q->where(function ($q2) use ($user) {
|
||||
$q2->where('owner_type', User::class)
|
||||
->where('owner_id', $user->id);
|
||||
});
|
||||
|
||||
// Workspace-owned namespaces (where user is a member)
|
||||
if ($workspaceIds->isNotEmpty()) {
|
||||
$q->orWhere(function ($q2) use ($workspaceIds) {
|
||||
$q2->where('owner_type', Workspace::class)
|
||||
->whereIn('owner_id', $workspaceIds);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helper Methods
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if a user has access to this namespace.
|
||||
*/
|
||||
public function isAccessibleBy(User $user): bool
|
||||
{
|
||||
// User owns the namespace directly
|
||||
if ($this->isOwnedByUser() && $this->owner_id === $user->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Workspace owns the namespace and user is a member
|
||||
if ($this->isOwnedByWorkspace()) {
|
||||
return $user->workspaces()->where('workspaces.id', $this->owner_id)->exists();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the billing context for this namespace.
|
||||
*
|
||||
* Returns workspace if set, otherwise falls back to owner's default workspace.
|
||||
*/
|
||||
public function getBillingContext(): ?Workspace
|
||||
{
|
||||
// Explicit workspace set for billing
|
||||
if ($this->workspace_id) {
|
||||
return $this->workspace;
|
||||
}
|
||||
|
||||
// Workspace-owned: use the owner workspace
|
||||
if ($this->isOwnedByWorkspace()) {
|
||||
return $this->owner;
|
||||
}
|
||||
|
||||
// User-owned: fall back to user's default workspace
|
||||
if ($this->isOwnedByUser() && $this->owner) {
|
||||
return $this->owner->defaultHostWorkspace();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the route key name for route model binding.
|
||||
*/
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'uuid';
|
||||
}
|
||||
}
|
||||
278
packages/core-php/src/Mod/Tenant/Services/NamespaceManager.php
Normal file
278
packages/core-php/src/Mod/Tenant/Services/NamespaceManager.php
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Services;
|
||||
|
||||
use Core\Mod\Tenant\Models\Namespace_;
|
||||
use Core\Mod\Tenant\Models\User;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Manager for namespace CRUD operations.
|
||||
*
|
||||
* Handles creating, updating, and managing namespaces with proper
|
||||
* validation and default handling.
|
||||
*/
|
||||
class NamespaceManager
|
||||
{
|
||||
public function __construct(
|
||||
protected NamespaceService $namespaceService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a namespace for a user.
|
||||
*/
|
||||
public function createForUser(User $user, array $data): Namespace_
|
||||
{
|
||||
$namespace = new Namespace_();
|
||||
$namespace->fill([
|
||||
'name' => $data['name'],
|
||||
'slug' => $data['slug'] ?? Str::slug($data['name']),
|
||||
'description' => $data['description'] ?? null,
|
||||
'icon' => $data['icon'] ?? 'folder',
|
||||
'color' => $data['color'] ?? 'zinc',
|
||||
'owner_type' => User::class,
|
||||
'owner_id' => $user->id,
|
||||
'workspace_id' => $data['workspace_id'] ?? null,
|
||||
'settings' => $data['settings'] ?? null,
|
||||
'is_default' => $data['is_default'] ?? false,
|
||||
'is_active' => $data['is_active'] ?? true,
|
||||
'sort_order' => $data['sort_order'] ?? 0,
|
||||
]);
|
||||
|
||||
// If this is marked as default, unset other defaults
|
||||
if ($namespace->is_default) {
|
||||
Namespace_::ownedByUser($user)
|
||||
->where('is_default', true)
|
||||
->update(['is_default' => false]);
|
||||
}
|
||||
|
||||
$namespace->save();
|
||||
|
||||
// Invalidate cache
|
||||
$this->namespaceService->invalidateUserCache($user);
|
||||
|
||||
return $namespace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a namespace for a workspace.
|
||||
*/
|
||||
public function createForWorkspace(Workspace $workspace, array $data): Namespace_
|
||||
{
|
||||
$namespace = new Namespace_();
|
||||
$namespace->fill([
|
||||
'name' => $data['name'],
|
||||
'slug' => $data['slug'] ?? Str::slug($data['name']),
|
||||
'description' => $data['description'] ?? null,
|
||||
'icon' => $data['icon'] ?? 'folder',
|
||||
'color' => $data['color'] ?? 'zinc',
|
||||
'owner_type' => Workspace::class,
|
||||
'owner_id' => $workspace->id,
|
||||
'workspace_id' => $workspace->id, // Billing context is the owner workspace
|
||||
'settings' => $data['settings'] ?? null,
|
||||
'is_default' => $data['is_default'] ?? false,
|
||||
'is_active' => $data['is_active'] ?? true,
|
||||
'sort_order' => $data['sort_order'] ?? 0,
|
||||
]);
|
||||
|
||||
// If this is marked as default, unset other defaults
|
||||
if ($namespace->is_default) {
|
||||
Namespace_::ownedByWorkspace($workspace)
|
||||
->where('is_default', true)
|
||||
->update(['is_default' => false]);
|
||||
}
|
||||
|
||||
$namespace->save();
|
||||
|
||||
// Invalidate cache for all workspace members
|
||||
foreach ($workspace->users as $member) {
|
||||
$this->namespaceService->invalidateUserCache($member);
|
||||
}
|
||||
|
||||
return $namespace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the default namespace for a user.
|
||||
*
|
||||
* This is typically called when a user first signs up.
|
||||
*/
|
||||
public function createDefaultForUser(User $user): Namespace_
|
||||
{
|
||||
return $this->createForUser($user, [
|
||||
'name' => 'Personal',
|
||||
'slug' => 'personal',
|
||||
'description' => 'Your personal workspace',
|
||||
'icon' => 'user',
|
||||
'color' => 'blue',
|
||||
'is_default' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the default namespace for a workspace.
|
||||
*
|
||||
* This is typically called when a workspace is created.
|
||||
*/
|
||||
public function createDefaultForWorkspace(Workspace $workspace): Namespace_
|
||||
{
|
||||
return $this->createForWorkspace($workspace, [
|
||||
'name' => $workspace->name,
|
||||
'slug' => 'default',
|
||||
'description' => "Default namespace for {$workspace->name}",
|
||||
'icon' => $workspace->icon ?? 'building',
|
||||
'color' => $workspace->color ?? 'zinc',
|
||||
'is_default' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a namespace.
|
||||
*/
|
||||
public function update(Namespace_ $namespace, array $data): Namespace_
|
||||
{
|
||||
$wasDefault = $namespace->is_default;
|
||||
|
||||
$namespace->fill(array_filter([
|
||||
'name' => $data['name'] ?? null,
|
||||
'slug' => $data['slug'] ?? null,
|
||||
'description' => $data['description'] ?? null,
|
||||
'icon' => $data['icon'] ?? null,
|
||||
'color' => $data['color'] ?? null,
|
||||
'workspace_id' => array_key_exists('workspace_id', $data) ? $data['workspace_id'] : $namespace->workspace_id,
|
||||
'settings' => $data['settings'] ?? null,
|
||||
'is_default' => $data['is_default'] ?? null,
|
||||
'is_active' => $data['is_active'] ?? null,
|
||||
'sort_order' => $data['sort_order'] ?? null,
|
||||
], fn ($v) => $v !== null));
|
||||
|
||||
// If becoming default, unset other defaults for same owner
|
||||
if (! $wasDefault && $namespace->is_default) {
|
||||
Namespace_::where('owner_type', $namespace->owner_type)
|
||||
->where('owner_id', $namespace->owner_id)
|
||||
->where('id', '!=', $namespace->id)
|
||||
->where('is_default', true)
|
||||
->update(['is_default' => false]);
|
||||
}
|
||||
|
||||
$namespace->save();
|
||||
|
||||
// Invalidate cache
|
||||
$this->namespaceService->invalidateCache($namespace->uuid);
|
||||
$this->invalidateCacheForOwner($namespace);
|
||||
|
||||
return $namespace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete (soft delete) a namespace.
|
||||
*/
|
||||
public function delete(Namespace_ $namespace): bool
|
||||
{
|
||||
// Invalidate cache first
|
||||
$this->namespaceService->invalidateCache($namespace->uuid);
|
||||
$this->invalidateCacheForOwner($namespace);
|
||||
|
||||
// If this was the default, make another one default
|
||||
if ($namespace->is_default) {
|
||||
$newDefault = Namespace_::where('owner_type', $namespace->owner_type)
|
||||
->where('owner_id', $namespace->owner_id)
|
||||
->where('id', '!=', $namespace->id)
|
||||
->active()
|
||||
->ordered()
|
||||
->first();
|
||||
|
||||
if ($newDefault) {
|
||||
$newDefault->update(['is_default' => true]);
|
||||
}
|
||||
}
|
||||
|
||||
return $namespace->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a soft-deleted namespace.
|
||||
*/
|
||||
public function restore(Namespace_ $namespace): bool
|
||||
{
|
||||
$result = $namespace->restore();
|
||||
|
||||
// Invalidate cache
|
||||
$this->namespaceService->invalidateCache($namespace->uuid);
|
||||
$this->invalidateCacheForOwner($namespace);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a namespace as the default for its owner.
|
||||
*/
|
||||
public function setAsDefault(Namespace_ $namespace): Namespace_
|
||||
{
|
||||
// Unset other defaults
|
||||
Namespace_::where('owner_type', $namespace->owner_type)
|
||||
->where('owner_id', $namespace->owner_id)
|
||||
->where('id', '!=', $namespace->id)
|
||||
->where('is_default', true)
|
||||
->update(['is_default' => false]);
|
||||
|
||||
// Set this as default
|
||||
$namespace->update(['is_default' => true]);
|
||||
|
||||
// Invalidate cache
|
||||
$this->invalidateCacheForOwner($namespace);
|
||||
|
||||
return $namespace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfer a namespace to a new owner.
|
||||
*/
|
||||
public function transfer(Namespace_ $namespace, User|Workspace $newOwner): Namespace_
|
||||
{
|
||||
$oldOwnerType = $namespace->owner_type;
|
||||
$oldOwnerId = $namespace->owner_id;
|
||||
|
||||
// Update ownership
|
||||
$namespace->update([
|
||||
'owner_type' => $newOwner::class,
|
||||
'owner_id' => $newOwner->id,
|
||||
'is_default' => false, // Can't be default in new context automatically
|
||||
]);
|
||||
|
||||
// Invalidate cache
|
||||
$this->namespaceService->invalidateCache($namespace->uuid);
|
||||
|
||||
// Invalidate for old owner
|
||||
if ($oldOwnerType === User::class) {
|
||||
$this->namespaceService->invalidateUserCache(User::find($oldOwnerId));
|
||||
} else {
|
||||
$workspace = Workspace::find($oldOwnerId);
|
||||
foreach ($workspace->users as $member) {
|
||||
$this->namespaceService->invalidateUserCache($member);
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate for new owner
|
||||
$this->invalidateCacheForOwner($namespace);
|
||||
|
||||
return $namespace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache for the owner of a namespace.
|
||||
*/
|
||||
protected function invalidateCacheForOwner(Namespace_ $namespace): void
|
||||
{
|
||||
if ($namespace->isOwnedByUser()) {
|
||||
$this->namespaceService->invalidateUserCache($namespace->owner);
|
||||
} else {
|
||||
foreach ($namespace->owner->users as $member) {
|
||||
$this->namespaceService->invalidateUserCache($member);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
288
packages/core-php/src/Mod/Tenant/Services/NamespaceService.php
Normal file
288
packages/core-php/src/Mod/Tenant/Services/NamespaceService.php
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Services;
|
||||
|
||||
use Core\Mod\Tenant\Models\Namespace_;
|
||||
use Core\Mod\Tenant\Models\User;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Service for namespace context management.
|
||||
*
|
||||
* Handles resolving the current namespace from session, checking access,
|
||||
* and providing namespace collections for users.
|
||||
*/
|
||||
class NamespaceService
|
||||
{
|
||||
/**
|
||||
* Cache TTL in seconds.
|
||||
*/
|
||||
protected const CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
/**
|
||||
* Get the current namespace from session/request.
|
||||
*/
|
||||
public function current(): ?Namespace_
|
||||
{
|
||||
// Try from request attributes first (set by middleware)
|
||||
if (request()->attributes->has('current_namespace')) {
|
||||
return request()->attributes->get('current_namespace');
|
||||
}
|
||||
|
||||
// Try from session
|
||||
$uuid = session('current_namespace_uuid');
|
||||
if ($uuid) {
|
||||
$namespace = $this->findByUuid($uuid);
|
||||
if ($namespace && $this->canAccess($namespace)) {
|
||||
return $namespace;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to user's default
|
||||
return $this->defaultForCurrentUser();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current namespace UUID from session.
|
||||
*/
|
||||
public function currentUuid(): ?string
|
||||
{
|
||||
return session('current_namespace_uuid');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current namespace in session.
|
||||
*/
|
||||
public function setCurrent(Namespace_|string $namespace): void
|
||||
{
|
||||
$uuid = $namespace instanceof Namespace_ ? $namespace->uuid : $namespace;
|
||||
|
||||
session(['current_namespace_uuid' => $uuid]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current namespace from session.
|
||||
*/
|
||||
public function clearCurrent(): void
|
||||
{
|
||||
session()->forget('current_namespace_uuid');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a namespace by UUID.
|
||||
*/
|
||||
public function findByUuid(string $uuid): ?Namespace_
|
||||
{
|
||||
return Cache::remember(
|
||||
"namespace:uuid:{$uuid}",
|
||||
self::CACHE_TTL,
|
||||
fn () => Namespace_::where('uuid', $uuid)->first()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a namespace by slug within an owner context.
|
||||
*/
|
||||
public function findBySlug(string $slug, User|Workspace $owner): ?Namespace_
|
||||
{
|
||||
return Namespace_::where('owner_type', $owner::class)
|
||||
->where('owner_id', $owner->id)
|
||||
->where('slug', $slug)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default namespace for the current authenticated user.
|
||||
*/
|
||||
public function defaultForCurrentUser(): ?Namespace_
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->defaultForUser($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default namespace for a user.
|
||||
*
|
||||
* Priority:
|
||||
* 1. User's default namespace (is_default = true)
|
||||
* 2. First active user-owned namespace
|
||||
* 3. First namespace from user's default workspace
|
||||
*/
|
||||
public function defaultForUser(User $user): ?Namespace_
|
||||
{
|
||||
// Try user's explicit default
|
||||
$default = Namespace_::ownedByUser($user)
|
||||
->where('is_default', true)
|
||||
->active()
|
||||
->first();
|
||||
|
||||
if ($default) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
// Try first user-owned namespace
|
||||
$userOwned = Namespace_::ownedByUser($user)
|
||||
->active()
|
||||
->ordered()
|
||||
->first();
|
||||
|
||||
if ($userOwned) {
|
||||
return $userOwned;
|
||||
}
|
||||
|
||||
// Try namespace from user's default workspace
|
||||
$workspace = $user->defaultHostWorkspace();
|
||||
if ($workspace) {
|
||||
return Namespace_::ownedByWorkspace($workspace)
|
||||
->active()
|
||||
->ordered()
|
||||
->first();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all namespaces accessible by the current user.
|
||||
*/
|
||||
public function accessibleByCurrentUser(): Collection
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return $this->accessibleByUser($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all namespaces accessible by a user.
|
||||
*/
|
||||
public function accessibleByUser(User $user): Collection
|
||||
{
|
||||
return Cache::remember(
|
||||
"user:{$user->id}:accessible_namespaces",
|
||||
self::CACHE_TTL,
|
||||
fn () => Namespace_::accessibleBy($user)
|
||||
->active()
|
||||
->ordered()
|
||||
->get()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all namespaces owned by a user.
|
||||
*/
|
||||
public function ownedByUser(User $user): Collection
|
||||
{
|
||||
return Namespace_::ownedByUser($user)
|
||||
->active()
|
||||
->ordered()
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all namespaces owned by a workspace.
|
||||
*/
|
||||
public function ownedByWorkspace(Workspace $workspace): Collection
|
||||
{
|
||||
return Namespace_::ownedByWorkspace($workspace)
|
||||
->active()
|
||||
->ordered()
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user can access a namespace.
|
||||
*/
|
||||
public function canAccess(Namespace_ $namespace): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $namespace->isAccessibleBy($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group namespaces by owner type for UI display.
|
||||
*
|
||||
* Returns:
|
||||
* [
|
||||
* 'personal' => Collection of user-owned namespaces,
|
||||
* 'workspaces' => [
|
||||
* ['workspace' => Workspace, 'namespaces' => Collection],
|
||||
* ...
|
||||
* ]
|
||||
* ]
|
||||
*/
|
||||
public function groupedForCurrentUser(): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return ['personal' => collect(), 'workspaces' => []];
|
||||
}
|
||||
|
||||
return $this->groupedForUser($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group namespaces by owner type for a user.
|
||||
*/
|
||||
public function groupedForUser(User $user): array
|
||||
{
|
||||
$personal = Namespace_::ownedByUser($user)
|
||||
->active()
|
||||
->ordered()
|
||||
->get();
|
||||
|
||||
$workspaces = [];
|
||||
foreach ($user->workspaces()->active()->get() as $workspace) {
|
||||
$namespaces = Namespace_::ownedByWorkspace($workspace)
|
||||
->active()
|
||||
->ordered()
|
||||
->get();
|
||||
|
||||
if ($namespaces->isNotEmpty()) {
|
||||
$workspaces[] = [
|
||||
'workspace' => $workspace,
|
||||
'namespaces' => $namespaces,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'personal' => $personal,
|
||||
'workspaces' => $workspaces,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate namespace cache for a user.
|
||||
*/
|
||||
public function invalidateUserCache(User $user): void
|
||||
{
|
||||
Cache::forget("user:{$user->id}:accessible_namespaces");
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate namespace cache by UUID.
|
||||
*/
|
||||
public function invalidateCache(string $uuid): void
|
||||
{
|
||||
Cache::forget("namespace:uuid:{$uuid}");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,417 @@
|
|||
<?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 bio/web tables - biolinks, domains, themes, blocks, etc.
|
||||
* Includes HLCRF support (parent_id for hierarchical biolinks).
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::disableForeignKeyConstraints();
|
||||
|
||||
// 1. Biolink Domains
|
||||
Schema::create('biolink_domains', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('host', 256)->unique();
|
||||
$table->string('scheme', 8)->default('https');
|
||||
$table->foreignId('biolink_id')->nullable();
|
||||
$table->string('custom_index_url', 512)->nullable();
|
||||
$table->string('custom_not_found_url', 512)->nullable();
|
||||
$table->boolean('is_enabled')->default(false);
|
||||
$table->enum('verification_status', ['pending', 'verified', 'failed'])->default('pending');
|
||||
$table->string('verification_token', 64)->nullable();
|
||||
$table->timestamp('verified_at')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['user_id', 'is_enabled']);
|
||||
$table->index(['workspace_id', 'is_enabled']);
|
||||
});
|
||||
|
||||
// 2. Biolink Projects
|
||||
Schema::create('biolink_projects', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name', 128);
|
||||
$table->string('color', 16)->default('#6366f1');
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['user_id', 'created_at']);
|
||||
$table->index(['workspace_id', 'created_at']);
|
||||
});
|
||||
|
||||
// 3. Biolink Themes
|
||||
Schema::create('biolink_themes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('name', 64);
|
||||
$table->string('slug', 64)->unique();
|
||||
$table->json('settings');
|
||||
$table->boolean('is_system')->default(false);
|
||||
$table->boolean('is_premium')->default(false);
|
||||
$table->boolean('is_gallery')->default(false);
|
||||
$table->string('category', 32)->nullable();
|
||||
$table->string('preview_image', 255)->nullable();
|
||||
$table->text('description')->nullable();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->unsignedSmallInteger('sort_order')->default(0);
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['is_system', 'is_active', 'sort_order']);
|
||||
$table->index(['user_id', 'is_active']);
|
||||
$table->index(['workspace_id', 'is_active']);
|
||||
$table->index(['is_gallery', 'is_active', 'category', 'sort_order'], 'gallery_filter_index');
|
||||
});
|
||||
|
||||
// 4. Biolinks
|
||||
Schema::create('biolinks', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('project_id')->nullable()->constrained('biolink_projects')->nullOnDelete();
|
||||
$table->foreignId('domain_id')->nullable()->constrained('biolink_domains')->nullOnDelete();
|
||||
$table->foreignId('theme_id')->nullable()->constrained('biolink_themes')->nullOnDelete();
|
||||
$table->foreignId('namespace_id')->nullable()->constrained('namespaces')->nullOnDelete();
|
||||
$table->foreignId('parent_id')->nullable()->constrained('biolinks')->nullOnDelete();
|
||||
$table->string('type', 32)->default('biolink');
|
||||
$table->string('url', 256);
|
||||
$table->string('location_url', 2048)->nullable();
|
||||
$table->json('settings')->nullable();
|
||||
$table->json('email_report_settings')->nullable();
|
||||
$table->unsignedBigInteger('clicks')->default(0);
|
||||
$table->unsignedBigInteger('unique_clicks')->default(0);
|
||||
$table->timestamp('start_date')->nullable();
|
||||
$table->timestamp('end_date')->nullable();
|
||||
$table->boolean('is_enabled')->default(true);
|
||||
$table->boolean('is_verified')->default(false);
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
$table->timestamp('last_click_at')->nullable();
|
||||
|
||||
$table->unique(['domain_id', 'url']);
|
||||
$table->index(['user_id', 'type', 'is_enabled']);
|
||||
$table->index(['user_id', 'project_id']);
|
||||
$table->index(['workspace_id', 'type']);
|
||||
$table->index('parent_id');
|
||||
});
|
||||
|
||||
// Add constraint to domains table
|
||||
Schema::table('biolink_domains', function (Blueprint $table) {
|
||||
$table->foreign('biolink_id')->references('id')->on('biolinks')->nullOnDelete();
|
||||
});
|
||||
|
||||
// 5. Biolink Blocks
|
||||
Schema::create('biolink_blocks', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('biolink_id')->constrained('biolinks')->cascadeOnDelete();
|
||||
$table->string('type', 32);
|
||||
$table->string('location_url', 512)->nullable();
|
||||
$table->json('settings')->nullable();
|
||||
$table->unsignedSmallInteger('order')->default(0);
|
||||
$table->unsignedBigInteger('clicks')->default(0);
|
||||
$table->timestamp('start_date')->nullable();
|
||||
$table->timestamp('end_date')->nullable();
|
||||
$table->boolean('is_enabled')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['biolink_id', 'is_enabled', 'order']);
|
||||
});
|
||||
|
||||
// 6. Biolink Pixels
|
||||
Schema::create('biolink_pixels', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('type', 32);
|
||||
$table->string('name', 64);
|
||||
$table->string('pixel_id', 128);
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['user_id', 'type']);
|
||||
$table->index(['workspace_id', 'type']);
|
||||
});
|
||||
|
||||
// 7. Biolink Pixel Pivot
|
||||
Schema::create('biolink_pixel', function (Blueprint $table) {
|
||||
$table->foreignId('biolink_id')->constrained('biolinks')->cascadeOnDelete();
|
||||
$table->foreignId('pixel_id')->constrained('biolink_pixels')->cascadeOnDelete();
|
||||
$table->primary(['biolink_id', 'pixel_id']);
|
||||
});
|
||||
|
||||
// 8. Click Stats (Aggregated)
|
||||
Schema::create('biolink_click_stats', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('biolink_id')->constrained('biolinks')->cascadeOnDelete();
|
||||
$table->foreignId('block_id')->nullable()->constrained('biolink_blocks')->nullOnDelete();
|
||||
$table->date('date');
|
||||
$table->unsignedTinyInteger('hour')->nullable();
|
||||
$table->unsignedInteger('clicks')->default(0);
|
||||
$table->unsignedInteger('unique_clicks')->default(0);
|
||||
$table->char('country_code', 2)->nullable();
|
||||
$table->enum('device_type', ['desktop', 'mobile', 'tablet', 'other'])->nullable();
|
||||
$table->string('referrer_host', 256)->nullable();
|
||||
$table->string('utm_source', 64)->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['biolink_id', 'block_id', 'date', 'hour', 'country_code', 'device_type', 'referrer_host', 'utm_source'], 'biolink_stats_unique');
|
||||
$table->index(['biolink_id', 'date']);
|
||||
$table->index(['biolink_id', 'date', 'country_code']);
|
||||
});
|
||||
|
||||
// 9. Clicks (Raw)
|
||||
Schema::create('biolink_clicks', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('biolink_id')->constrained('biolinks')->cascadeOnDelete();
|
||||
$table->foreignId('block_id')->nullable()->constrained('biolink_blocks')->nullOnDelete();
|
||||
$table->string('visitor_hash', 64)->nullable();
|
||||
$table->char('country_code', 2)->nullable();
|
||||
$table->string('region', 64)->nullable();
|
||||
$table->enum('device_type', ['desktop', 'mobile', 'tablet', 'other'])->default('other');
|
||||
$table->string('os_name', 32)->nullable();
|
||||
$table->string('browser_name', 32)->nullable();
|
||||
$table->string('referrer_host', 256)->nullable();
|
||||
$table->string('utm_source', 64)->nullable();
|
||||
$table->string('utm_medium', 64)->nullable();
|
||||
$table->string('utm_campaign', 64)->nullable();
|
||||
$table->boolean('is_unique')->default(false);
|
||||
$table->timestamp('created_at');
|
||||
|
||||
$table->index(['biolink_id', 'created_at']);
|
||||
$table->index(['biolink_id', 'country_code']);
|
||||
$table->index(['biolink_id', 'device_type']);
|
||||
$table->index(['biolink_id', 'referrer_host']);
|
||||
$table->index(['block_id', 'created_at']);
|
||||
});
|
||||
|
||||
// 10. Notification Handlers
|
||||
Schema::create('biolink_notification_handlers', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('biolink_id')->constrained('biolinks')->cascadeOnDelete();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name', 128);
|
||||
$table->enum('type', ['webhook', 'email', 'slack', 'discord', 'telegram']);
|
||||
$table->json('settings');
|
||||
$table->json('events')->default(json_encode(['click']));
|
||||
$table->boolean('is_enabled')->default(true);
|
||||
$table->unsignedInteger('trigger_count')->default(0);
|
||||
$table->timestamp('last_triggered_at')->nullable();
|
||||
$table->timestamp('last_failed_at')->nullable();
|
||||
$table->unsignedSmallInteger('consecutive_failures')->default(0);
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['biolink_id', 'is_enabled']);
|
||||
$table->index(['workspace_id', 'type']);
|
||||
});
|
||||
|
||||
// 11. Push Configs
|
||||
Schema::create('biolink_push_configs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('biolink_id')->unique()->constrained('biolinks')->cascadeOnDelete();
|
||||
$table->text('vapid_public_key');
|
||||
$table->text('vapid_private_key');
|
||||
$table->string('default_icon_url', 512)->nullable();
|
||||
$table->boolean('prompt_enabled')->default(true);
|
||||
$table->unsignedSmallInteger('prompt_delay_seconds')->default(5);
|
||||
$table->unsignedSmallInteger('prompt_min_pageviews')->default(2);
|
||||
$table->boolean('is_enabled')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
// 12. Push Subscribers
|
||||
Schema::create('biolink_push_subscribers', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('biolink_id')->constrained('biolinks')->cascadeOnDelete();
|
||||
$table->string('subscriber_hash', 64)->unique();
|
||||
$table->text('endpoint');
|
||||
$table->string('key_auth', 128);
|
||||
$table->string('key_p256dh', 128);
|
||||
$table->char('country_code', 2)->nullable();
|
||||
$table->string('city_name', 64)->nullable();
|
||||
$table->string('os_name', 32)->nullable();
|
||||
$table->string('browser_name', 32)->nullable();
|
||||
$table->string('browser_language', 16)->nullable();
|
||||
$table->enum('device_type', ['desktop', 'mobile', 'tablet', 'other'])->default('other');
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamp('last_notification_at')->nullable();
|
||||
$table->unsignedInteger('notifications_received')->default(0);
|
||||
$table->timestamp('subscribed_at');
|
||||
$table->timestamp('unsubscribed_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['biolink_id', 'is_active']);
|
||||
$table->index(['biolink_id', 'country_code']);
|
||||
$table->index(['biolink_id', 'device_type']);
|
||||
});
|
||||
|
||||
// 13. Push Notifications
|
||||
Schema::create('biolink_push_notifications', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('biolink_id')->constrained('biolinks')->cascadeOnDelete();
|
||||
$table->string('title', 64);
|
||||
$table->string('body', 256)->nullable();
|
||||
$table->string('url', 512)->nullable();
|
||||
$table->string('icon_url', 512)->nullable();
|
||||
$table->string('badge_url', 512)->nullable();
|
||||
$table->enum('segment', ['all', 'desktop', 'mobile', 'country'])->default('all');
|
||||
$table->string('segment_value', 64)->nullable();
|
||||
$table->unsignedInteger('total_subscribers')->default(0);
|
||||
$table->unsignedInteger('sent_count')->default(0);
|
||||
$table->unsignedInteger('delivered_count')->default(0);
|
||||
$table->unsignedInteger('clicked_count')->default(0);
|
||||
$table->unsignedInteger('failed_count')->default(0);
|
||||
$table->enum('status', ['draft', 'scheduled', 'sending', 'sent', 'failed'])->default('draft');
|
||||
$table->timestamp('scheduled_at')->nullable();
|
||||
$table->timestamp('sent_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['biolink_id', 'status']);
|
||||
$table->index(['status', 'scheduled_at']);
|
||||
});
|
||||
|
||||
// 14. Push Deliveries
|
||||
Schema::create('biolink_push_deliveries', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('notification_id')->constrained('biolink_push_notifications')->cascadeOnDelete();
|
||||
$table->foreignId('subscriber_id')->constrained('biolink_push_subscribers')->cascadeOnDelete();
|
||||
$table->enum('status', ['pending', 'sent', 'delivered', 'clicked', 'failed'])->default('pending');
|
||||
$table->string('error_message', 256)->nullable();
|
||||
$table->unsignedTinyInteger('retry_count')->default(0);
|
||||
$table->timestamp('sent_at')->nullable();
|
||||
$table->timestamp('delivered_at')->nullable();
|
||||
$table->timestamp('clicked_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['notification_id', 'subscriber_id']);
|
||||
$table->index(['notification_id', 'status']);
|
||||
});
|
||||
|
||||
// 15. PWAs
|
||||
Schema::create('biolink_pwas', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('biolink_id')->unique()->constrained('biolinks')->cascadeOnDelete();
|
||||
$table->string('name', 128);
|
||||
$table->string('short_name', 32)->nullable();
|
||||
$table->string('description', 256)->nullable();
|
||||
$table->string('theme_color', 16)->default('#6366f1');
|
||||
$table->string('background_color', 16)->default('#ffffff');
|
||||
$table->enum('display', ['standalone', 'fullscreen', 'minimal-ui', 'browser'])->default('standalone');
|
||||
$table->enum('orientation', ['any', 'natural', 'portrait', 'landscape'])->default('any');
|
||||
$table->string('icon_url', 512)->nullable();
|
||||
$table->string('icon_maskable_url', 512)->nullable();
|
||||
$table->json('screenshots')->nullable();
|
||||
$table->json('shortcuts')->nullable();
|
||||
$table->string('start_url', 512)->nullable();
|
||||
$table->string('scope', 512)->nullable();
|
||||
$table->string('lang', 8)->default('en');
|
||||
$table->enum('dir', ['ltr', 'rtl', 'auto'])->default('auto');
|
||||
$table->unsignedInteger('installs')->default(0);
|
||||
$table->boolean('is_enabled')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
// 16. Submissions
|
||||
Schema::create('biolink_submissions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('biolink_id')->constrained('biolinks')->cascadeOnDelete();
|
||||
$table->foreignId('block_id')->constrained('biolink_blocks')->cascadeOnDelete();
|
||||
$table->enum('type', ['email', 'phone', 'contact']);
|
||||
$table->json('data');
|
||||
$table->string('ip_hash', 64)->nullable();
|
||||
$table->char('country_code', 2)->nullable();
|
||||
$table->boolean('notification_sent')->default(false);
|
||||
$table->timestamp('notified_at')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['biolink_id', 'created_at']);
|
||||
$table->index(['block_id', 'created_at']);
|
||||
$table->index(['biolink_id', 'type']);
|
||||
$table->index('type');
|
||||
});
|
||||
|
||||
// 17. Templates
|
||||
Schema::create('biolink_templates', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('name', 128);
|
||||
$table->string('slug', 128)->unique();
|
||||
$table->string('category', 64);
|
||||
$table->text('description')->nullable();
|
||||
$table->json('blocks_json');
|
||||
$table->json('settings_json');
|
||||
$table->json('placeholders')->nullable();
|
||||
$table->string('preview_image', 255)->nullable();
|
||||
$table->json('tags')->nullable();
|
||||
$table->boolean('is_system')->default(false);
|
||||
$table->boolean('is_premium')->default(false);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->unsignedSmallInteger('sort_order')->default(0);
|
||||
$table->unsignedInteger('usage_count')->default(0);
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['category', 'is_active', 'sort_order']);
|
||||
$table->index(['is_system', 'is_active', 'sort_order']);
|
||||
$table->index(['user_id', 'is_active']);
|
||||
$table->index(['workspace_id', 'is_active']);
|
||||
$table->index('category');
|
||||
});
|
||||
|
||||
// 18. Theme Favourites
|
||||
Schema::create('theme_favourites', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('theme_id')->constrained('biolink_themes')->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['user_id', 'theme_id']);
|
||||
$table->index(['user_id', 'created_at']);
|
||||
});
|
||||
|
||||
Schema::enableForeignKeyConstraints();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::disableForeignKeyConstraints();
|
||||
Schema::dropIfExists('theme_favourites');
|
||||
Schema::dropIfExists('biolink_templates');
|
||||
Schema::dropIfExists('biolink_submissions');
|
||||
Schema::dropIfExists('biolink_pwas');
|
||||
Schema::dropIfExists('biolink_push_deliveries');
|
||||
Schema::dropIfExists('biolink_push_notifications');
|
||||
Schema::dropIfExists('biolink_push_subscribers');
|
||||
Schema::dropIfExists('biolink_push_configs');
|
||||
Schema::dropIfExists('biolink_notification_handlers');
|
||||
Schema::dropIfExists('biolink_clicks');
|
||||
Schema::dropIfExists('biolink_click_stats');
|
||||
Schema::dropIfExists('biolink_pixel');
|
||||
Schema::dropIfExists('biolink_pixels');
|
||||
Schema::dropIfExists('biolink_blocks');
|
||||
Schema::table('biolink_domains', function (Blueprint $table) {
|
||||
$table->dropForeign(['biolink_id']);
|
||||
});
|
||||
Schema::dropIfExists('biolinks');
|
||||
Schema::dropIfExists('biolink_themes');
|
||||
Schema::dropIfExists('biolink_projects');
|
||||
Schema::dropIfExists('biolink_domains');
|
||||
Schema::enableForeignKeyConstraints();
|
||||
}
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue