feat(workspace): implement workspace teams and permissions management with enhanced member model
This commit is contained in:
parent
537f01672b
commit
b0e3ef461f
27 changed files with 4045 additions and 27 deletions
|
|
@ -21,7 +21,7 @@ Before creating bug reports, please check the existing issues to avoid duplicate
|
|||
|
||||
### Security Vulnerabilities
|
||||
|
||||
**DO NOT** open public issues for security vulnerabilities. Instead, email security concerns to: **dev@host.uk.com**
|
||||
**DO NOT** open public issues for security vulnerabilities. Instead, email security concerns to: **support@host.uk.com**
|
||||
|
||||
We take security seriously and will respond promptly to valid security reports.
|
||||
|
||||
|
|
@ -282,6 +282,6 @@ If you have questions about contributing, feel free to:
|
|||
|
||||
- Open a **GitHub Discussion**
|
||||
- Create an **issue** labeled "question"
|
||||
- Email **dev@host.uk.com**
|
||||
- Email **support@host.uk.com**
|
||||
|
||||
Thank you for contributing! 🎉
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ Sponsor development of specific features:
|
|||
- **Silver ($2,000-$4,999)** - Choose a medium feature from v1.x
|
||||
- **Bronze ($500-$1,999)** - Choose a small feature or bug fix
|
||||
|
||||
Contact: dev@host.uk.com
|
||||
Contact: support@host.uk.com
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
|
||||
**Please do not report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
Instead, please report them via email to: **dev@host.uk.com**
|
||||
Instead, please report them via email to: **support@host.uk.com**
|
||||
|
||||
You should receive a response within 48 hours. If for some reason you do not, please follow up via email to ensure we received your original message.
|
||||
|
||||
|
|
@ -160,7 +160,7 @@ For sensitive security reports, you may encrypt your message using our PGP key:
|
|||
|
||||
## Contact
|
||||
|
||||
- **Security Email:** dev@host.uk.com
|
||||
- **Security Email:** support@host.uk.com
|
||||
- **General Support:** https://github.com/host-uk/core-php/discussions
|
||||
- **GitHub Security Advisories:** https://github.com/host-uk/core-php/security/advisories
|
||||
|
||||
|
|
|
|||
|
|
@ -147,4 +147,4 @@ $order = CreateOrder::run($user, $validated);
|
|||
- 📖 [Documentation](https://docs.example.com)
|
||||
- 💬 [GitHub Discussions](https://github.com/host-uk/core-php/discussions)
|
||||
- 🐛 [Issue Tracker](https://github.com/host-uk/core-php/issues)
|
||||
- 📧 [Email Support](mailto:dev@host.uk.com)
|
||||
- 📧 [Email Support](mailto:support@host.uk.com)
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ This page documents all security-related changes, fixes, and improvements to Cor
|
|||
|
||||
If you discover a security vulnerability, please follow our [Responsible Disclosure](/security/responsible-disclosure) policy.
|
||||
|
||||
**Contact:** dev@host.uk.com
|
||||
**Contact:** support@host.uk.com
|
||||
|
||||
## Security Update Policy
|
||||
|
||||
|
|
|
|||
|
|
@ -567,7 +567,7 @@ composer update
|
|||
|
||||
If you discover a security vulnerability, please email:
|
||||
|
||||
**dev@host.uk.com**
|
||||
**support@host.uk.com**
|
||||
|
||||
Do not create public GitHub issues for security vulnerabilities.
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ We take the security of Core PHP Framework seriously. If you believe you have fo
|
|||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Email:** dev@host.uk.com
|
||||
**Email:** support@host.uk.com
|
||||
|
||||
**PGP Key:** Available on request
|
||||
|
||||
|
|
@ -158,7 +158,7 @@ https://docs.core-php.dev/security/responsible-disclosure
|
|||
|
||||
## Contact
|
||||
|
||||
For security issues: dev@host.uk.com
|
||||
For security issues: support@host.uk.com
|
||||
|
||||
For general inquiries: https://github.com/host-uk/core-php/issues
|
||||
|
||||
|
|
|
|||
|
|
@ -5,10 +5,12 @@ declare(strict_types=1);
|
|||
namespace Core\Mod\Api\Controllers;
|
||||
|
||||
use Core\Front\Controller;
|
||||
use Core\Mod\Api\Models\ApiKey;
|
||||
use Core\Mod\Mcp\Models\McpApiRequest;
|
||||
use Core\Mod\Mcp\Models\McpToolCall;
|
||||
use Core\Mod\Mcp\Models\McpToolVersion;
|
||||
use Core\Mod\Mcp\Services\McpWebhookDispatcher;
|
||||
use Core\Mod\Api\Models\ApiKey;
|
||||
use Core\Mod\Mcp\Services\ToolVersionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
|
@ -61,6 +63,9 @@ class McpApiController extends Controller
|
|||
* List tools for a specific server.
|
||||
*
|
||||
* GET /api/v1/mcp/servers/{id}/tools
|
||||
*
|
||||
* Query params:
|
||||
* - include_versions: bool - include version info for each tool
|
||||
*/
|
||||
public function tools(Request $request, string $id): JsonResponse
|
||||
{
|
||||
|
|
@ -70,10 +75,35 @@ class McpApiController extends Controller
|
|||
return response()->json(['error' => 'Server not found'], 404);
|
||||
}
|
||||
|
||||
$tools = $server['tools'] ?? [];
|
||||
$includeVersions = $request->boolean('include_versions', false);
|
||||
|
||||
// Optionally enrich tools with version information
|
||||
if ($includeVersions) {
|
||||
$versionService = app(ToolVersionService::class);
|
||||
$tools = collect($tools)->map(function ($tool) use ($id, $versionService) {
|
||||
$toolName = $tool['name'] ?? '';
|
||||
$latestVersion = $versionService->getLatestVersion($id, $toolName);
|
||||
|
||||
$tool['versioning'] = [
|
||||
'latest_version' => $latestVersion?->version ?? ToolVersionService::DEFAULT_VERSION,
|
||||
'is_versioned' => $latestVersion !== null,
|
||||
'deprecated' => $latestVersion?->is_deprecated ?? false,
|
||||
];
|
||||
|
||||
// If version exists, use its schema (may differ from YAML)
|
||||
if ($latestVersion?->input_schema) {
|
||||
$tool['inputSchema'] = $latestVersion->input_schema;
|
||||
}
|
||||
|
||||
return $tool;
|
||||
})->all();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'server' => $id,
|
||||
'tools' => $server['tools'] ?? [],
|
||||
'count' => count($server['tools'] ?? []),
|
||||
'tools' => $tools,
|
||||
'count' => count($tools),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -81,13 +111,20 @@ class McpApiController extends Controller
|
|||
* Execute a tool on an MCP server.
|
||||
*
|
||||
* POST /api/v1/mcp/tools/call
|
||||
*
|
||||
* Request body:
|
||||
* - server: string (required)
|
||||
* - tool: string (required)
|
||||
* - arguments: array (optional)
|
||||
* - version: string (optional) - semver version to use, defaults to latest
|
||||
*/
|
||||
public function callTool(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'server' => 'required|string',
|
||||
'tool' => 'required|string',
|
||||
'server' => 'required|string|max:64',
|
||||
'tool' => 'required|string|max:128',
|
||||
'arguments' => 'nullable|array',
|
||||
'version' => 'nullable|string|max:32',
|
||||
]);
|
||||
|
||||
$server = $this->loadServerFull($validated['server']);
|
||||
|
|
@ -95,12 +132,64 @@ class McpApiController extends Controller
|
|||
return response()->json(['error' => 'Server not found'], 404);
|
||||
}
|
||||
|
||||
// Verify tool exists
|
||||
// Verify tool exists in server definition
|
||||
$toolDef = collect($server['tools'] ?? [])->firstWhere('name', $validated['tool']);
|
||||
if (! $toolDef) {
|
||||
return response()->json(['error' => 'Tool not found'], 404);
|
||||
}
|
||||
|
||||
// Version resolution
|
||||
$versionService = app(ToolVersionService::class);
|
||||
$versionResult = $versionService->resolveVersion(
|
||||
$validated['server'],
|
||||
$validated['tool'],
|
||||
$validated['version'] ?? null
|
||||
);
|
||||
|
||||
// If version was requested but is sunset, block the call
|
||||
if ($versionResult['error']) {
|
||||
$error = $versionResult['error'];
|
||||
|
||||
// Sunset versions return 410 Gone
|
||||
$status = ($error['code'] ?? '') === 'TOOL_VERSION_SUNSET' ? 410 : 400;
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => $error['message'] ?? 'Version error',
|
||||
'error_code' => $error['code'] ?? 'VERSION_ERROR',
|
||||
'server' => $validated['server'],
|
||||
'tool' => $validated['tool'],
|
||||
'requested_version' => $validated['version'] ?? null,
|
||||
'latest_version' => $error['latest_version'] ?? null,
|
||||
'migration_notes' => $error['migration_notes'] ?? null,
|
||||
], $status);
|
||||
}
|
||||
|
||||
/** @var McpToolVersion|null $toolVersion */
|
||||
$toolVersion = $versionResult['version'];
|
||||
$deprecationWarning = $versionResult['warning'];
|
||||
|
||||
// Use versioned schema if available for validation
|
||||
$schemaForValidation = $toolVersion?->input_schema ?? $toolDef['inputSchema'] ?? null;
|
||||
if ($schemaForValidation) {
|
||||
$validationErrors = $this->validateToolArguments(
|
||||
['inputSchema' => $schemaForValidation],
|
||||
$validated['arguments'] ?? []
|
||||
);
|
||||
|
||||
if (! empty($validationErrors)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'Validation failed',
|
||||
'error_code' => 'VALIDATION_ERROR',
|
||||
'validation_errors' => $validationErrors,
|
||||
'server' => $validated['server'],
|
||||
'tool' => $validated['tool'],
|
||||
'version' => $toolVersion?->version ?? 'unversioned',
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
// Get API key for logging
|
||||
$apiKey = $request->attributes->get('api_key');
|
||||
$workspace = $apiKey?->workspace;
|
||||
|
|
@ -127,14 +216,33 @@ class McpApiController extends Controller
|
|||
'success' => true,
|
||||
'server' => $validated['server'],
|
||||
'tool' => $validated['tool'],
|
||||
'version' => $toolVersion?->version ?? ToolVersionService::DEFAULT_VERSION,
|
||||
'result' => $result,
|
||||
'duration_ms' => $durationMs,
|
||||
];
|
||||
|
||||
// Include deprecation warning if applicable
|
||||
if ($deprecationWarning) {
|
||||
$response['_warnings'] = [$deprecationWarning];
|
||||
}
|
||||
|
||||
// Log full request for debugging/replay
|
||||
$this->logApiRequest($request, $validated, 200, $response, $durationMs, $apiKey);
|
||||
|
||||
return response()->json($response);
|
||||
// Build response with deprecation headers if needed
|
||||
$jsonResponse = response()->json($response);
|
||||
|
||||
if ($deprecationWarning) {
|
||||
$jsonResponse->header('X-MCP-Deprecation-Warning', $deprecationWarning['message'] ?? 'Version deprecated');
|
||||
if (isset($deprecationWarning['sunset_at'])) {
|
||||
$jsonResponse->header('X-MCP-Sunset-At', $deprecationWarning['sunset_at']);
|
||||
}
|
||||
if (isset($deprecationWarning['latest_version'])) {
|
||||
$jsonResponse->header('X-MCP-Latest-Version', $deprecationWarning['latest_version']);
|
||||
}
|
||||
}
|
||||
|
||||
return $jsonResponse;
|
||||
} catch (\Throwable $e) {
|
||||
$durationMs = (int) ((microtime(true) - $startTime) * 1000);
|
||||
|
||||
|
|
@ -148,6 +256,7 @@ class McpApiController extends Controller
|
|||
'error' => $e->getMessage(),
|
||||
'server' => $validated['server'],
|
||||
'tool' => $validated['tool'],
|
||||
'version' => $toolVersion?->version ?? ToolVersionService::DEFAULT_VERSION,
|
||||
];
|
||||
|
||||
// Log full request for debugging/replay
|
||||
|
|
@ -157,6 +266,130 @@ class McpApiController extends Controller
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate tool arguments against a JSON schema.
|
||||
*
|
||||
* @return array<string> Validation error messages
|
||||
*/
|
||||
protected function validateToolArguments(array $toolDef, array $arguments): array
|
||||
{
|
||||
$inputSchema = $toolDef['inputSchema'] ?? null;
|
||||
|
||||
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) {
|
||||
if (! isset($properties[$key])) {
|
||||
if (($inputSchema['additionalProperties'] ?? true) === 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}";
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version history for a specific tool.
|
||||
*
|
||||
* GET /api/v1/mcp/servers/{server}/tools/{tool}/versions
|
||||
*/
|
||||
public function toolVersions(Request $request, string $server, string $tool): JsonResponse
|
||||
{
|
||||
$serverConfig = $this->loadServerFull($server);
|
||||
if (! $serverConfig) {
|
||||
return response()->json(['error' => 'Server not found'], 404);
|
||||
}
|
||||
|
||||
// Verify tool exists in server definition
|
||||
$toolDef = collect($serverConfig['tools'] ?? [])->firstWhere('name', $tool);
|
||||
if (! $toolDef) {
|
||||
return response()->json(['error' => 'Tool not found'], 404);
|
||||
}
|
||||
|
||||
$versionService = app(ToolVersionService::class);
|
||||
$versions = $versionService->getVersionHistory($server, $tool);
|
||||
|
||||
return response()->json([
|
||||
'server' => $server,
|
||||
'tool' => $tool,
|
||||
'versions' => $versions->map(fn (McpToolVersion $v) => $v->toApiArray())->values(),
|
||||
'count' => $versions->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific version of a tool.
|
||||
*
|
||||
* GET /api/v1/mcp/servers/{server}/tools/{tool}/versions/{version}
|
||||
*/
|
||||
public function toolVersion(Request $request, string $server, string $tool, string $version): JsonResponse
|
||||
{
|
||||
$versionService = app(ToolVersionService::class);
|
||||
$toolVersion = $versionService->getToolAtVersion($server, $tool, $version);
|
||||
|
||||
if (! $toolVersion) {
|
||||
return response()->json(['error' => 'Version not found'], 404);
|
||||
}
|
||||
|
||||
$response = response()->json($toolVersion->toApiArray());
|
||||
|
||||
// Add deprecation headers if applicable
|
||||
if ($deprecationWarning = $toolVersion->getDeprecationWarning()) {
|
||||
$response->header('X-MCP-Deprecation-Warning', $deprecationWarning['message'] ?? 'Version deprecated');
|
||||
if (isset($deprecationWarning['sunset_at'])) {
|
||||
$response->header('X-MCP-Sunset-At', $deprecationWarning['sunset_at']);
|
||||
}
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a resource from an MCP server.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Core\Mod\Api\Controllers\EntitlementApiController;
|
||||
use Core\Mod\Api\Controllers\McpApiController;
|
||||
use Core\Mod\Api\Controllers\SeoReportController;
|
||||
use Core\Mod\Api\Controllers\UnifiedPixelController;
|
||||
use Core\Mod\Mcp\Middleware\McpApiKeyAuth;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
@ -84,6 +84,14 @@ Route::middleware(['throttle:120,1', McpApiKeyAuth::class, 'api.scope.enforce'])
|
|||
Route::get('/servers/{id}/tools', [McpApiController::class, 'tools'])
|
||||
->name('servers.tools');
|
||||
|
||||
// Tool version history (read)
|
||||
Route::get('/servers/{server}/tools/{tool}/versions', [McpApiController::class, 'toolVersions'])
|
||||
->name('tools.versions');
|
||||
|
||||
// Specific tool version (read)
|
||||
Route::get('/servers/{server}/tools/{tool}/versions/{version}', [McpApiController::class, 'toolVersion'])
|
||||
->name('tools.version');
|
||||
|
||||
// Tool execution (write)
|
||||
Route::post('/tools/call', [McpApiController::class, 'callTool'])
|
||||
->name('tools.call');
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ use Core\Mod\Mcp\Services\McpQuotaService;
|
|||
use Core\Mod\Mcp\Services\ToolAnalyticsService;
|
||||
use Core\Mod\Mcp\Services\ToolDependencyService;
|
||||
use Core\Mod\Mcp\Services\ToolRegistry;
|
||||
use Core\Mod\Mcp\Services\ToolVersionService;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
|
|
@ -45,6 +46,7 @@ class Boot extends ServiceProvider
|
|||
$this->app->singleton(McpQuotaService::class);
|
||||
$this->app->singleton(ToolDependencyService::class);
|
||||
$this->app->singleton(AuditLogService::class);
|
||||
$this->app->singleton(ToolVersionService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -78,6 +80,7 @@ class Boot extends ServiceProvider
|
|||
$event->livewire('mcp.admin.tool-analytics-detail', View\Modal\Admin\ToolAnalyticsDetail::class);
|
||||
$event->livewire('mcp.admin.quota-usage', View\Modal\Admin\QuotaUsage::class);
|
||||
$event->livewire('mcp.admin.audit-log-viewer', View\Modal\Admin\AuditLogViewer::class);
|
||||
$event->livewire('mcp.admin.tool-version-manager', View\Modal\Admin\ToolVersionManager::class);
|
||||
}
|
||||
|
||||
public function onConsole(ConsoleBooting $event): void
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ use Core\Mod\Mcp\View\Modal\Admin\ApiKeyManager;
|
|||
use Core\Mod\Mcp\View\Modal\Admin\AuditLogViewer;
|
||||
use Core\Mod\Mcp\View\Modal\Admin\McpPlayground;
|
||||
use Core\Mod\Mcp\View\Modal\Admin\Playground;
|
||||
use Core\Mod\Mcp\View\Modal\Admin\QuotaUsage;
|
||||
use Core\Mod\Mcp\View\Modal\Admin\RequestLog;
|
||||
use Core\Mod\Mcp\View\Modal\Admin\ToolAnalyticsDashboard;
|
||||
use Core\Mod\Mcp\View\Modal\Admin\ToolAnalyticsDetail;
|
||||
use Core\Mod\Mcp\View\Modal\Admin\ToolVersionManager;
|
||||
use Core\Website\Mcp\Controllers\McpRegistryController;
|
||||
use Core\Website\Mcp\View\Modal\Dashboard;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
|
@ -57,4 +59,12 @@ Route::prefix('mcp')->name('mcp.')->group(function () {
|
|||
// Audit log viewer (compliance and security)
|
||||
Route::get('audit-log', AuditLogViewer::class)
|
||||
->name('audit-log');
|
||||
|
||||
// Tool version management (Hades only)
|
||||
Route::get('versions', ToolVersionManager::class)
|
||||
->name('versions');
|
||||
|
||||
// Quota usage overview
|
||||
Route::get('quotas', QuotaUsage::class)
|
||||
->name('quotas');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Mcp\Services;
|
||||
|
||||
use Core\Mod\Mcp\Models\McpToolVersion;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
|
@ -71,11 +72,15 @@ class ToolRegistry
|
|||
/**
|
||||
* Get all tools for a specific server.
|
||||
*
|
||||
* @return Collection<int, array{name: string, description: string, category: string, inputSchema: array, examples: array}>
|
||||
* @return Collection<int, array{name: string, description: string, category: string, inputSchema: array, examples: array, version: string|null}>
|
||||
*/
|
||||
public function getToolsForServer(string $serverId): Collection
|
||||
public function getToolsForServer(string $serverId, bool $includeVersionInfo = false): Collection
|
||||
{
|
||||
return Cache::remember("mcp:playground:tools:{$serverId}", self::CACHE_TTL, function () use ($serverId) {
|
||||
$cacheKey = $includeVersionInfo
|
||||
? "mcp:playground:tools:{$serverId}:versioned"
|
||||
: "mcp:playground:tools:{$serverId}";
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($serverId, $includeVersionInfo) {
|
||||
$server = $this->loadServerFull($serverId);
|
||||
|
||||
if (! $server) {
|
||||
|
|
@ -83,16 +88,40 @@ class ToolRegistry
|
|||
}
|
||||
|
||||
return collect($server['tools'] ?? [])
|
||||
->map(function ($tool) {
|
||||
->map(function ($tool) use ($serverId, $includeVersionInfo) {
|
||||
$name = $tool['name'];
|
||||
$baseVersion = $tool['version'] ?? ToolVersionService::DEFAULT_VERSION;
|
||||
|
||||
return [
|
||||
$result = [
|
||||
'name' => $name,
|
||||
'description' => $tool['description'] ?? $tool['purpose'] ?? '',
|
||||
'category' => $this->extractCategory($tool),
|
||||
'inputSchema' => $tool['inputSchema'] ?? ['type' => 'object', 'properties' => $tool['parameters'] ?? []],
|
||||
'examples' => $this->examples[$name] ?? $this->generateExampleFromSchema($tool['inputSchema'] ?? []),
|
||||
'version' => $baseVersion,
|
||||
];
|
||||
|
||||
// Optionally enrich with database version info
|
||||
if ($includeVersionInfo) {
|
||||
$latestVersion = McpToolVersion::forServer($serverId)
|
||||
->forTool($name)
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if ($latestVersion) {
|
||||
$result['version'] = $latestVersion->version;
|
||||
$result['version_status'] = $latestVersion->status;
|
||||
$result['is_deprecated'] = $latestVersion->is_deprecated;
|
||||
$result['sunset_at'] = $latestVersion->sunset_at?->toIso8601String();
|
||||
|
||||
// Use versioned schema if available
|
||||
if ($latestVersion->input_schema) {
|
||||
$result['inputSchema'] = $latestVersion->input_schema;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
})
|
||||
->values();
|
||||
});
|
||||
|
|
|
|||
478
packages/core-mcp/src/Mod/Mcp/Services/ToolVersionService.php
Normal file
478
packages/core-mcp/src/Mod/Mcp/Services/ToolVersionService.php
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Mcp\Services;
|
||||
|
||||
use Core\Mod\Mcp\Models\McpToolVersion;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Tool Version Service - manages MCP tool versioning for backwards compatibility.
|
||||
*
|
||||
* Provides version registration, lookup, comparison, and migration support
|
||||
* for maintaining compatibility with running agents during tool schema changes.
|
||||
*/
|
||||
class ToolVersionService
|
||||
{
|
||||
/**
|
||||
* Cache key prefix for version lookups.
|
||||
*/
|
||||
protected const CACHE_PREFIX = 'mcp:tool_version:';
|
||||
|
||||
/**
|
||||
* Cache TTL for version data (5 minutes).
|
||||
*/
|
||||
protected const CACHE_TTL = 300;
|
||||
|
||||
/**
|
||||
* Default version for unversioned tools.
|
||||
*/
|
||||
public const DEFAULT_VERSION = '1.0.0';
|
||||
|
||||
/**
|
||||
* Register a new tool version.
|
||||
*
|
||||
* @param array $options Additional options (changelog, migration_notes, mark_latest)
|
||||
*/
|
||||
public function registerVersion(
|
||||
string $serverId,
|
||||
string $toolName,
|
||||
string $version,
|
||||
?array $inputSchema = null,
|
||||
?array $outputSchema = null,
|
||||
?string $description = null,
|
||||
array $options = []
|
||||
): McpToolVersion {
|
||||
// Validate semver format
|
||||
if (! $this->isValidSemver($version)) {
|
||||
throw new \InvalidArgumentException("Invalid semver version: {$version}");
|
||||
}
|
||||
|
||||
// Check if version already exists
|
||||
$existing = McpToolVersion::forServer($serverId)
|
||||
->forTool($toolName)
|
||||
->forVersion($version)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
// Update existing version
|
||||
$existing->update([
|
||||
'input_schema' => $inputSchema ?? $existing->input_schema,
|
||||
'output_schema' => $outputSchema ?? $existing->output_schema,
|
||||
'description' => $description ?? $existing->description,
|
||||
'changelog' => $options['changelog'] ?? $existing->changelog,
|
||||
'migration_notes' => $options['migration_notes'] ?? $existing->migration_notes,
|
||||
]);
|
||||
|
||||
if ($options['mark_latest'] ?? false) {
|
||||
$existing->markAsLatest();
|
||||
}
|
||||
|
||||
$this->clearCache($serverId, $toolName);
|
||||
|
||||
return $existing->fresh();
|
||||
}
|
||||
|
||||
// Create new version
|
||||
$toolVersion = McpToolVersion::create([
|
||||
'server_id' => $serverId,
|
||||
'tool_name' => $toolName,
|
||||
'version' => $version,
|
||||
'input_schema' => $inputSchema,
|
||||
'output_schema' => $outputSchema,
|
||||
'description' => $description,
|
||||
'changelog' => $options['changelog'] ?? null,
|
||||
'migration_notes' => $options['migration_notes'] ?? null,
|
||||
'is_latest' => false,
|
||||
]);
|
||||
|
||||
// Mark as latest if requested or if it's the first version
|
||||
$isFirst = McpToolVersion::forServer($serverId)->forTool($toolName)->count() === 1;
|
||||
|
||||
if (($options['mark_latest'] ?? false) || $isFirst) {
|
||||
$toolVersion->markAsLatest();
|
||||
}
|
||||
|
||||
$this->clearCache($serverId, $toolName);
|
||||
|
||||
Log::info('MCP tool version registered', [
|
||||
'server_id' => $serverId,
|
||||
'tool_name' => $toolName,
|
||||
'version' => $version,
|
||||
'is_latest' => $toolVersion->is_latest,
|
||||
]);
|
||||
|
||||
return $toolVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a tool at a specific version.
|
||||
*
|
||||
* Returns null if version doesn't exist. Use getLatestVersion() for fallback.
|
||||
*/
|
||||
public function getToolAtVersion(string $serverId, string $toolName, string $version): ?McpToolVersion
|
||||
{
|
||||
$cacheKey = self::CACHE_PREFIX."{$serverId}:{$toolName}:{$version}";
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($serverId, $toolName, $version) {
|
||||
return McpToolVersion::forServer($serverId)
|
||||
->forTool($toolName)
|
||||
->forVersion($version)
|
||||
->first();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest version of a tool.
|
||||
*/
|
||||
public function getLatestVersion(string $serverId, string $toolName): ?McpToolVersion
|
||||
{
|
||||
$cacheKey = self::CACHE_PREFIX."{$serverId}:{$toolName}:latest";
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($serverId, $toolName) {
|
||||
// First try to find explicitly marked latest
|
||||
$latest = McpToolVersion::forServer($serverId)
|
||||
->forTool($toolName)
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if ($latest) {
|
||||
return $latest;
|
||||
}
|
||||
|
||||
// Fallback to newest version by semver
|
||||
return McpToolVersion::forServer($serverId)
|
||||
->forTool($toolName)
|
||||
->active()
|
||||
->orderByVersion('desc')
|
||||
->first();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a tool version, falling back to latest if not specified.
|
||||
*
|
||||
* @return array{version: McpToolVersion|null, warning: array|null, error: array|null}
|
||||
*/
|
||||
public function resolveVersion(string $serverId, string $toolName, ?string $requestedVersion = null): array
|
||||
{
|
||||
// If no version requested, use latest
|
||||
if ($requestedVersion === null) {
|
||||
$version = $this->getLatestVersion($serverId, $toolName);
|
||||
|
||||
return [
|
||||
'version' => $version,
|
||||
'warning' => null,
|
||||
'error' => $version === null ? [
|
||||
'code' => 'TOOL_NOT_FOUND',
|
||||
'message' => "No versions found for tool {$serverId}:{$toolName}",
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
// Look up specific version
|
||||
$version = $this->getToolAtVersion($serverId, $toolName, $requestedVersion);
|
||||
|
||||
if (! $version) {
|
||||
return [
|
||||
'version' => null,
|
||||
'warning' => null,
|
||||
'error' => [
|
||||
'code' => 'VERSION_NOT_FOUND',
|
||||
'message' => "Version {$requestedVersion} not found for tool {$serverId}:{$toolName}",
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// Check if sunset
|
||||
if ($version->is_sunset) {
|
||||
return [
|
||||
'version' => null,
|
||||
'warning' => null,
|
||||
'error' => $version->getSunsetError(),
|
||||
];
|
||||
}
|
||||
|
||||
// Check if deprecated (warning, not error)
|
||||
$warning = $version->getDeprecationWarning();
|
||||
|
||||
return [
|
||||
'version' => $version,
|
||||
'warning' => $warning,
|
||||
'error' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a version is deprecated.
|
||||
*/
|
||||
public function isDeprecated(string $serverId, string $toolName, string $version): bool
|
||||
{
|
||||
$toolVersion = $this->getToolAtVersion($serverId, $toolName, $version);
|
||||
|
||||
return $toolVersion?->is_deprecated ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a version is sunset (blocked).
|
||||
*/
|
||||
public function isSunset(string $serverId, string $toolName, string $version): bool
|
||||
{
|
||||
$toolVersion = $this->getToolAtVersion($serverId, $toolName, $version);
|
||||
|
||||
return $toolVersion?->is_sunset ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two semver versions.
|
||||
*
|
||||
* @return int -1 if $a < $b, 0 if equal, 1 if $a > $b
|
||||
*/
|
||||
public function compareVersions(string $a, string $b): int
|
||||
{
|
||||
return version_compare(
|
||||
$this->normalizeSemver($a),
|
||||
$this->normalizeSemver($b)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get version history for a tool.
|
||||
*
|
||||
* @return Collection<int, McpToolVersion>
|
||||
*/
|
||||
public function getVersionHistory(string $serverId, string $toolName): Collection
|
||||
{
|
||||
return McpToolVersion::forServer($serverId)
|
||||
->forTool($toolName)
|
||||
->orderByVersion('desc')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to migrate a tool call from an old version schema to a new one.
|
||||
*
|
||||
* This is a best-effort migration that:
|
||||
* - Preserves arguments that exist in both schemas
|
||||
* - Applies defaults for new required arguments where possible
|
||||
* - Returns warnings for arguments that couldn't be migrated
|
||||
*
|
||||
* @return array{arguments: array, warnings: array, success: bool}
|
||||
*/
|
||||
public function migrateToolCall(
|
||||
string $serverId,
|
||||
string $toolName,
|
||||
string $fromVersion,
|
||||
string $toVersion,
|
||||
array $arguments
|
||||
): array {
|
||||
$fromTool = $this->getToolAtVersion($serverId, $toolName, $fromVersion);
|
||||
$toTool = $this->getToolAtVersion($serverId, $toolName, $toVersion);
|
||||
|
||||
if (! $fromTool || ! $toTool) {
|
||||
return [
|
||||
'arguments' => $arguments,
|
||||
'warnings' => ['Could not load version schemas for migration'],
|
||||
'success' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$toSchema = $toTool->input_schema ?? [];
|
||||
$toProperties = $toSchema['properties'] ?? [];
|
||||
$toRequired = $toSchema['required'] ?? [];
|
||||
|
||||
$migratedArgs = [];
|
||||
$warnings = [];
|
||||
|
||||
// Copy over arguments that exist in the new schema
|
||||
foreach ($arguments as $key => $value) {
|
||||
if (isset($toProperties[$key])) {
|
||||
$migratedArgs[$key] = $value;
|
||||
} else {
|
||||
$warnings[] = "Argument '{$key}' removed in version {$toVersion}";
|
||||
}
|
||||
}
|
||||
|
||||
// Check for new required arguments without defaults
|
||||
foreach ($toRequired as $requiredKey) {
|
||||
if (! isset($migratedArgs[$requiredKey])) {
|
||||
// Try to apply default from schema
|
||||
if (isset($toProperties[$requiredKey]['default'])) {
|
||||
$migratedArgs[$requiredKey] = $toProperties[$requiredKey]['default'];
|
||||
$warnings[] = "Applied default value for new required argument '{$requiredKey}'";
|
||||
} else {
|
||||
$warnings[] = "Missing required argument '{$requiredKey}' added in version {$toVersion}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'arguments' => $migratedArgs,
|
||||
'warnings' => $warnings,
|
||||
'success' => empty(array_filter($warnings, fn ($w) => str_starts_with($w, 'Missing required'))),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deprecate a tool version with optional sunset date.
|
||||
*/
|
||||
public function deprecateVersion(
|
||||
string $serverId,
|
||||
string $toolName,
|
||||
string $version,
|
||||
?Carbon $sunsetAt = null
|
||||
): ?McpToolVersion {
|
||||
$toolVersion = McpToolVersion::forServer($serverId)
|
||||
->forTool($toolName)
|
||||
->forVersion($version)
|
||||
->first();
|
||||
|
||||
if (! $toolVersion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$toolVersion->deprecate($sunsetAt);
|
||||
$this->clearCache($serverId, $toolName);
|
||||
|
||||
Log::info('MCP tool version deprecated', [
|
||||
'server_id' => $serverId,
|
||||
'tool_name' => $toolName,
|
||||
'version' => $version,
|
||||
'sunset_at' => $sunsetAt?->toIso8601String(),
|
||||
]);
|
||||
|
||||
return $toolVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tools with version info for a server.
|
||||
*
|
||||
* @return Collection<string, array{latest: McpToolVersion|null, versions: Collection}>
|
||||
*/
|
||||
public function getToolsWithVersions(string $serverId): Collection
|
||||
{
|
||||
$versions = McpToolVersion::forServer($serverId)
|
||||
->orderByVersion('desc')
|
||||
->get();
|
||||
|
||||
return $versions->groupBy('tool_name')
|
||||
->map(function ($toolVersions, $toolName) {
|
||||
return [
|
||||
'tool_name' => $toolName,
|
||||
'latest' => $toolVersions->firstWhere('is_latest', true) ?? $toolVersions->first(),
|
||||
'versions' => $toolVersions,
|
||||
'version_count' => $toolVersions->count(),
|
||||
'has_deprecated' => $toolVersions->contains(fn ($v) => $v->is_deprecated),
|
||||
'has_sunset' => $toolVersions->contains(fn ($v) => $v->is_sunset),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all unique servers that have versioned tools.
|
||||
*/
|
||||
public function getServersWithVersions(): Collection
|
||||
{
|
||||
return McpToolVersion::select('server_id')
|
||||
->distinct()
|
||||
->orderBy('server_id')
|
||||
->pluck('server_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync tool versions from YAML server definitions.
|
||||
*
|
||||
* Call this during deployment to register/update versions from server configs.
|
||||
*
|
||||
* @param array $serverConfig Parsed YAML server configuration
|
||||
* @param string $version Version to register (e.g., from deployment tag)
|
||||
*/
|
||||
public function syncFromServerConfig(array $serverConfig, string $version, bool $markLatest = true): int
|
||||
{
|
||||
$serverId = $serverConfig['id'] ?? null;
|
||||
$tools = $serverConfig['tools'] ?? [];
|
||||
|
||||
if (! $serverId || empty($tools)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$registered = 0;
|
||||
|
||||
foreach ($tools as $tool) {
|
||||
$toolName = $tool['name'] ?? null;
|
||||
if (! $toolName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->registerVersion(
|
||||
serverId: $serverId,
|
||||
toolName: $toolName,
|
||||
version: $version,
|
||||
inputSchema: $tool['inputSchema'] ?? null,
|
||||
outputSchema: $tool['outputSchema'] ?? null,
|
||||
description: $tool['description'] ?? $tool['purpose'] ?? null,
|
||||
options: [
|
||||
'mark_latest' => $markLatest,
|
||||
]
|
||||
);
|
||||
|
||||
$registered++;
|
||||
}
|
||||
|
||||
return $registered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics about tool versions.
|
||||
*/
|
||||
public function getStats(): array
|
||||
{
|
||||
return [
|
||||
'total_versions' => McpToolVersion::count(),
|
||||
'total_tools' => McpToolVersion::select('server_id', 'tool_name')
|
||||
->distinct()
|
||||
->count(),
|
||||
'deprecated_count' => McpToolVersion::deprecated()->count(),
|
||||
'sunset_count' => McpToolVersion::sunset()->count(),
|
||||
'servers' => $this->getServersWithVersions()->count(),
|
||||
];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Protected Methods
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validate semver format.
|
||||
*/
|
||||
protected function isValidSemver(string $version): bool
|
||||
{
|
||||
// Basic semver pattern: major.minor.patch with optional prerelease/build
|
||||
$pattern = '/^(\d+)\.(\d+)\.(\d+)(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/';
|
||||
|
||||
return (bool) preg_match($pattern, $version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize semver for comparison (removes prerelease/build metadata).
|
||||
*/
|
||||
protected function normalizeSemver(string $version): string
|
||||
{
|
||||
// Remove prerelease and build metadata for basic comparison
|
||||
return preg_replace('/[-+].*$/', '', $version) ?? $version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache for a tool's versions.
|
||||
*/
|
||||
protected function clearCache(string $serverId, string $toolName): void
|
||||
{
|
||||
// Clear specific version caches would require tracking all versions
|
||||
// For simplicity, we use a short TTL and let cache naturally expire
|
||||
Cache::forget(self::CACHE_PREFIX."{$serverId}:{$toolName}:latest");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,441 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Mcp\Tests\Unit;
|
||||
|
||||
use Core\Mod\Mcp\Services\ToolVersionService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ToolVersionServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected ToolVersionService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->service = new ToolVersionService;
|
||||
}
|
||||
|
||||
public function test_can_register_new_version(): void
|
||||
{
|
||||
$version = $this->service->registerVersion(
|
||||
serverId: 'test-server',
|
||||
toolName: 'test_tool',
|
||||
version: '1.0.0',
|
||||
inputSchema: ['type' => 'object', 'properties' => ['query' => ['type' => 'string']]],
|
||||
description: 'A test tool',
|
||||
options: ['mark_latest' => true]
|
||||
);
|
||||
|
||||
$this->assertSame('test-server', $version->server_id);
|
||||
$this->assertSame('test_tool', $version->tool_name);
|
||||
$this->assertSame('1.0.0', $version->version);
|
||||
$this->assertTrue($version->is_latest);
|
||||
}
|
||||
|
||||
public function test_first_version_is_automatically_latest(): void
|
||||
{
|
||||
$version = $this->service->registerVersion(
|
||||
serverId: 'test-server',
|
||||
toolName: 'test_tool',
|
||||
version: '1.0.0',
|
||||
);
|
||||
|
||||
$this->assertTrue($version->is_latest);
|
||||
}
|
||||
|
||||
public function test_can_get_tool_at_specific_version(): void
|
||||
{
|
||||
$this->service->registerVersion('test-server', 'test_tool', '1.0.0');
|
||||
$this->service->registerVersion('test-server', 'test_tool', '2.0.0');
|
||||
|
||||
$v1 = $this->service->getToolAtVersion('test-server', 'test_tool', '1.0.0');
|
||||
$v2 = $this->service->getToolAtVersion('test-server', 'test_tool', '2.0.0');
|
||||
|
||||
$this->assertSame('1.0.0', $v1->version);
|
||||
$this->assertSame('2.0.0', $v2->version);
|
||||
}
|
||||
|
||||
public function test_get_latest_version(): void
|
||||
{
|
||||
$this->service->registerVersion('test-server', 'test_tool', '1.0.0');
|
||||
$v2 = $this->service->registerVersion('test-server', 'test_tool', '2.0.0', options: ['mark_latest' => true]);
|
||||
|
||||
$latest = $this->service->getLatestVersion('test-server', 'test_tool');
|
||||
|
||||
$this->assertSame('2.0.0', $latest->version);
|
||||
$this->assertTrue($latest->is_latest);
|
||||
}
|
||||
|
||||
public function test_resolve_version_returns_latest_when_no_version_specified(): void
|
||||
{
|
||||
$this->service->registerVersion('test-server', 'test_tool', '1.0.0');
|
||||
$this->service->registerVersion('test-server', 'test_tool', '2.0.0', options: ['mark_latest' => true]);
|
||||
|
||||
$result = $this->service->resolveVersion('test-server', 'test_tool', null);
|
||||
|
||||
$this->assertNotNull($result['version']);
|
||||
$this->assertSame('2.0.0', $result['version']->version);
|
||||
$this->assertNull($result['warning']);
|
||||
$this->assertNull($result['error']);
|
||||
}
|
||||
|
||||
public function test_resolve_version_returns_specific_version(): void
|
||||
{
|
||||
$this->service->registerVersion('test-server', 'test_tool', '1.0.0');
|
||||
$this->service->registerVersion('test-server', 'test_tool', '2.0.0', options: ['mark_latest' => true]);
|
||||
|
||||
$result = $this->service->resolveVersion('test-server', 'test_tool', '1.0.0');
|
||||
|
||||
$this->assertNotNull($result['version']);
|
||||
$this->assertSame('1.0.0', $result['version']->version);
|
||||
}
|
||||
|
||||
public function test_resolve_version_returns_error_for_nonexistent_version(): void
|
||||
{
|
||||
$this->service->registerVersion('test-server', 'test_tool', '1.0.0');
|
||||
|
||||
$result = $this->service->resolveVersion('test-server', 'test_tool', '9.9.9');
|
||||
|
||||
$this->assertNull($result['version']);
|
||||
$this->assertNotNull($result['error']);
|
||||
$this->assertSame('VERSION_NOT_FOUND', $result['error']['code']);
|
||||
}
|
||||
|
||||
public function test_resolve_deprecated_version_returns_warning(): void
|
||||
{
|
||||
$version = $this->service->registerVersion('test-server', 'test_tool', '1.0.0');
|
||||
$version->deprecate(Carbon::now()->addDays(30));
|
||||
|
||||
$this->service->registerVersion('test-server', 'test_tool', '2.0.0', options: ['mark_latest' => true]);
|
||||
|
||||
$result = $this->service->resolveVersion('test-server', 'test_tool', '1.0.0');
|
||||
|
||||
$this->assertNotNull($result['version']);
|
||||
$this->assertNotNull($result['warning']);
|
||||
$this->assertSame('TOOL_VERSION_DEPRECATED', $result['warning']['code']);
|
||||
}
|
||||
|
||||
public function test_resolve_sunset_version_returns_error(): void
|
||||
{
|
||||
$version = $this->service->registerVersion('test-server', 'test_tool', '1.0.0');
|
||||
$version->deprecated_at = Carbon::now()->subDays(60);
|
||||
$version->sunset_at = Carbon::now()->subDays(30);
|
||||
$version->save();
|
||||
|
||||
$this->service->registerVersion('test-server', 'test_tool', '2.0.0', options: ['mark_latest' => true]);
|
||||
|
||||
$result = $this->service->resolveVersion('test-server', 'test_tool', '1.0.0');
|
||||
|
||||
$this->assertNull($result['version']);
|
||||
$this->assertNotNull($result['error']);
|
||||
$this->assertSame('TOOL_VERSION_SUNSET', $result['error']['code']);
|
||||
}
|
||||
|
||||
public function test_is_deprecated(): void
|
||||
{
|
||||
$version = $this->service->registerVersion('test-server', 'test_tool', '1.0.0');
|
||||
$version->deprecate();
|
||||
|
||||
$this->assertTrue($this->service->isDeprecated('test-server', 'test_tool', '1.0.0'));
|
||||
}
|
||||
|
||||
public function test_is_sunset(): void
|
||||
{
|
||||
$version = $this->service->registerVersion('test-server', 'test_tool', '1.0.0');
|
||||
$version->deprecated_at = Carbon::now()->subDays(60);
|
||||
$version->sunset_at = Carbon::now()->subDays(30);
|
||||
$version->save();
|
||||
|
||||
$this->assertTrue($this->service->isSunset('test-server', 'test_tool', '1.0.0'));
|
||||
}
|
||||
|
||||
public function test_compare_versions(): void
|
||||
{
|
||||
$this->assertSame(-1, $this->service->compareVersions('1.0.0', '2.0.0'));
|
||||
$this->assertSame(0, $this->service->compareVersions('1.0.0', '1.0.0'));
|
||||
$this->assertSame(1, $this->service->compareVersions('2.0.0', '1.0.0'));
|
||||
$this->assertSame(-1, $this->service->compareVersions('1.0.0', '1.0.1'));
|
||||
$this->assertSame(-1, $this->service->compareVersions('1.0.0', '1.1.0'));
|
||||
}
|
||||
|
||||
public function test_get_version_history(): void
|
||||
{
|
||||
$this->service->registerVersion('test-server', 'test_tool', '1.0.0');
|
||||
$this->service->registerVersion('test-server', 'test_tool', '1.1.0');
|
||||
$this->service->registerVersion('test-server', 'test_tool', '2.0.0');
|
||||
|
||||
$history = $this->service->getVersionHistory('test-server', 'test_tool');
|
||||
|
||||
$this->assertCount(3, $history);
|
||||
// Should be ordered by version desc
|
||||
$this->assertSame('2.0.0', $history[0]->version);
|
||||
$this->assertSame('1.1.0', $history[1]->version);
|
||||
$this->assertSame('1.0.0', $history[2]->version);
|
||||
}
|
||||
|
||||
public function test_migrate_tool_call(): void
|
||||
{
|
||||
$this->service->registerVersion(
|
||||
serverId: 'test-server',
|
||||
toolName: 'test_tool',
|
||||
version: '1.0.0',
|
||||
inputSchema: [
|
||||
'type' => 'object',
|
||||
'properties' => ['query' => ['type' => 'string']],
|
||||
'required' => ['query'],
|
||||
]
|
||||
);
|
||||
|
||||
$this->service->registerVersion(
|
||||
serverId: 'test-server',
|
||||
toolName: 'test_tool',
|
||||
version: '2.0.0',
|
||||
inputSchema: [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'query' => ['type' => 'string'],
|
||||
'limit' => ['type' => 'integer', 'default' => 10],
|
||||
],
|
||||
'required' => ['query', 'limit'],
|
||||
]
|
||||
);
|
||||
|
||||
$result = $this->service->migrateToolCall(
|
||||
serverId: 'test-server',
|
||||
toolName: 'test_tool',
|
||||
fromVersion: '1.0.0',
|
||||
toVersion: '2.0.0',
|
||||
arguments: ['query' => 'SELECT * FROM users']
|
||||
);
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
$this->assertSame('SELECT * FROM users', $result['arguments']['query']);
|
||||
$this->assertSame(10, $result['arguments']['limit']); // Default applied
|
||||
}
|
||||
|
||||
public function test_deprecate_version(): void
|
||||
{
|
||||
$this->service->registerVersion('test-server', 'test_tool', '1.0.0');
|
||||
|
||||
$sunsetDate = Carbon::now()->addDays(30);
|
||||
$deprecatedVersion = $this->service->deprecateVersion(
|
||||
'test-server',
|
||||
'test_tool',
|
||||
'1.0.0',
|
||||
$sunsetDate
|
||||
);
|
||||
|
||||
$this->assertNotNull($deprecatedVersion->deprecated_at);
|
||||
$this->assertSame($sunsetDate->toDateString(), $deprecatedVersion->sunset_at->toDateString());
|
||||
}
|
||||
|
||||
public function test_get_tools_with_versions(): void
|
||||
{
|
||||
$this->service->registerVersion('test-server', 'tool_a', '1.0.0');
|
||||
$this->service->registerVersion('test-server', 'tool_a', '2.0.0', options: ['mark_latest' => true]);
|
||||
$this->service->registerVersion('test-server', 'tool_b', '1.0.0');
|
||||
|
||||
$tools = $this->service->getToolsWithVersions('test-server');
|
||||
|
||||
$this->assertCount(2, $tools);
|
||||
$this->assertArrayHasKey('tool_a', $tools);
|
||||
$this->assertArrayHasKey('tool_b', $tools);
|
||||
$this->assertSame(2, $tools['tool_a']['version_count']);
|
||||
$this->assertSame(1, $tools['tool_b']['version_count']);
|
||||
}
|
||||
|
||||
public function test_get_servers_with_versions(): void
|
||||
{
|
||||
$this->service->registerVersion('server-a', 'tool', '1.0.0');
|
||||
$this->service->registerVersion('server-b', 'tool', '1.0.0');
|
||||
|
||||
$servers = $this->service->getServersWithVersions();
|
||||
|
||||
$this->assertCount(2, $servers);
|
||||
$this->assertContains('server-a', $servers);
|
||||
$this->assertContains('server-b', $servers);
|
||||
}
|
||||
|
||||
public function test_sync_from_server_config(): void
|
||||
{
|
||||
$config = [
|
||||
'id' => 'test-server',
|
||||
'tools' => [
|
||||
[
|
||||
'name' => 'tool_a',
|
||||
'description' => 'Tool A',
|
||||
'inputSchema' => ['type' => 'object'],
|
||||
],
|
||||
[
|
||||
'name' => 'tool_b',
|
||||
'purpose' => 'Tool B purpose',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$registered = $this->service->syncFromServerConfig($config, '1.0.0');
|
||||
|
||||
$this->assertSame(2, $registered);
|
||||
|
||||
$toolA = $this->service->getToolAtVersion('test-server', 'tool_a', '1.0.0');
|
||||
$toolB = $this->service->getToolAtVersion('test-server', 'tool_b', '1.0.0');
|
||||
|
||||
$this->assertNotNull($toolA);
|
||||
$this->assertNotNull($toolB);
|
||||
$this->assertSame('Tool A', $toolA->description);
|
||||
$this->assertSame('Tool B purpose', $toolB->description);
|
||||
}
|
||||
|
||||
public function test_get_stats(): void
|
||||
{
|
||||
$this->service->registerVersion('server-a', 'tool_a', '1.0.0');
|
||||
$this->service->registerVersion('server-a', 'tool_a', '2.0.0');
|
||||
$this->service->registerVersion('server-b', 'tool_b', '1.0.0');
|
||||
|
||||
$stats = $this->service->getStats();
|
||||
|
||||
$this->assertSame(3, $stats['total_versions']);
|
||||
$this->assertSame(2, $stats['total_tools']);
|
||||
$this->assertSame(2, $stats['servers']);
|
||||
}
|
||||
|
||||
public function test_invalid_semver_throws_exception(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Invalid semver version');
|
||||
|
||||
$this->service->registerVersion('test-server', 'test_tool', 'invalid');
|
||||
}
|
||||
|
||||
public function test_valid_semver_formats(): void
|
||||
{
|
||||
// Basic versions
|
||||
$v1 = $this->service->registerVersion('test-server', 'tool', '1.0.0');
|
||||
$this->assertSame('1.0.0', $v1->version);
|
||||
|
||||
// Prerelease
|
||||
$v2 = $this->service->registerVersion('test-server', 'tool', '2.0.0-beta');
|
||||
$this->assertSame('2.0.0-beta', $v2->version);
|
||||
|
||||
// Prerelease with dots
|
||||
$v3 = $this->service->registerVersion('test-server', 'tool', '2.0.0-alpha.1');
|
||||
$this->assertSame('2.0.0-alpha.1', $v3->version);
|
||||
|
||||
// Build metadata
|
||||
$v4 = $this->service->registerVersion('test-server', 'tool', '2.0.0+build.123');
|
||||
$this->assertSame('2.0.0+build.123', $v4->version);
|
||||
}
|
||||
|
||||
public function test_updating_existing_version(): void
|
||||
{
|
||||
$original = $this->service->registerVersion(
|
||||
serverId: 'test-server',
|
||||
toolName: 'test_tool',
|
||||
version: '1.0.0',
|
||||
description: 'Original description'
|
||||
);
|
||||
|
||||
$updated = $this->service->registerVersion(
|
||||
serverId: 'test-server',
|
||||
toolName: 'test_tool',
|
||||
version: '1.0.0',
|
||||
description: 'Updated description'
|
||||
);
|
||||
|
||||
$this->assertSame($original->id, $updated->id);
|
||||
$this->assertSame('Updated description', $updated->description);
|
||||
}
|
||||
|
||||
public function test_model_compare_schema_with(): void
|
||||
{
|
||||
$v1 = $this->service->registerVersion(
|
||||
serverId: 'test-server',
|
||||
toolName: 'test_tool',
|
||||
version: '1.0.0',
|
||||
inputSchema: [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'query' => ['type' => 'string'],
|
||||
'format' => ['type' => 'string'],
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$v2 = $this->service->registerVersion(
|
||||
serverId: 'test-server',
|
||||
toolName: 'test_tool',
|
||||
version: '2.0.0',
|
||||
inputSchema: [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'query' => ['type' => 'string', 'maxLength' => 1000], // Changed
|
||||
'limit' => ['type' => 'integer'], // Added
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$diff = $v1->compareSchemaWith($v2);
|
||||
|
||||
$this->assertContains('limit', $diff['added']);
|
||||
$this->assertContains('format', $diff['removed']);
|
||||
$this->assertArrayHasKey('query', $diff['changed']);
|
||||
}
|
||||
|
||||
public function test_model_mark_as_latest(): void
|
||||
{
|
||||
$v1 = $this->service->registerVersion('test-server', 'test_tool', '1.0.0');
|
||||
$v2 = $this->service->registerVersion('test-server', 'test_tool', '2.0.0');
|
||||
|
||||
$v2->markAsLatest();
|
||||
|
||||
$this->assertFalse($v1->fresh()->is_latest);
|
||||
$this->assertTrue($v2->fresh()->is_latest);
|
||||
}
|
||||
|
||||
public function test_model_status_attribute(): void
|
||||
{
|
||||
$version = $this->service->registerVersion('test-server', 'test_tool', '1.0.0');
|
||||
|
||||
$this->assertSame('latest', $version->status);
|
||||
|
||||
$version->is_latest = false;
|
||||
$version->save();
|
||||
$this->assertSame('active', $version->fresh()->status);
|
||||
|
||||
$version->deprecated_at = Carbon::now()->subDay();
|
||||
$version->save();
|
||||
$this->assertSame('deprecated', $version->fresh()->status);
|
||||
|
||||
$version->sunset_at = Carbon::now()->subDay();
|
||||
$version->save();
|
||||
$this->assertSame('sunset', $version->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_model_to_api_array(): void
|
||||
{
|
||||
$version = $this->service->registerVersion(
|
||||
serverId: 'test-server',
|
||||
toolName: 'test_tool',
|
||||
version: '1.0.0',
|
||||
inputSchema: ['type' => 'object'],
|
||||
description: 'Test tool',
|
||||
options: ['changelog' => 'Initial release']
|
||||
);
|
||||
|
||||
$array = $version->toApiArray();
|
||||
|
||||
$this->assertSame('test-server', $array['server_id']);
|
||||
$this->assertSame('test_tool', $array['tool_name']);
|
||||
$this->assertSame('1.0.0', $array['version']);
|
||||
$this->assertTrue($array['is_latest']);
|
||||
$this->assertSame('latest', $array['status']);
|
||||
$this->assertSame('Test tool', $array['description']);
|
||||
$this->assertSame('Initial release', $array['changelog']);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,537 @@
|
|||
{{--
|
||||
MCP Tool Version Manager.
|
||||
|
||||
Admin interface for managing tool version lifecycles,
|
||||
viewing schema changes between versions, and setting deprecation schedules.
|
||||
--}}
|
||||
|
||||
<div class="space-y-6">
|
||||
{{-- Header --}}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<core:heading size="xl">{{ __('Tool Versions') }}</core:heading>
|
||||
<core:subheading>Manage MCP tool version lifecycles and backwards compatibility</core:subheading>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:button wire:click="openRegisterModal" icon="plus">
|
||||
Register Version
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Stats Cards --}}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Total Versions</div>
|
||||
<div class="mt-1 text-2xl font-semibold text-zinc-900 dark:text-white">
|
||||
{{ number_format($this->stats['total_versions']) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Unique Tools</div>
|
||||
<div class="mt-1 text-2xl font-semibold text-zinc-900 dark:text-white">
|
||||
{{ number_format($this->stats['total_tools']) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Servers</div>
|
||||
<div class="mt-1 text-2xl font-semibold text-zinc-900 dark:text-white">
|
||||
{{ number_format($this->stats['servers']) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Deprecated</div>
|
||||
<div class="mt-1 text-2xl font-semibold text-amber-600 dark:text-amber-400">
|
||||
{{ number_format($this->stats['deprecated_count']) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Sunset</div>
|
||||
<div class="mt-1 text-2xl font-semibold text-red-600 dark:text-red-400">
|
||||
{{ number_format($this->stats['sunset_count']) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Filters --}}
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<flux:input wire:model.live.debounce.300ms="search" placeholder="Search tools, servers, versions..." icon="magnifying-glass" />
|
||||
</div>
|
||||
<flux:select wire:model.live="server" placeholder="All servers">
|
||||
<flux:select.option value="">All servers</flux:select.option>
|
||||
@foreach ($this->servers as $serverId)
|
||||
<flux:select.option value="{{ $serverId }}">{{ $serverId }}</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:select wire:model.live="status" placeholder="All statuses">
|
||||
<flux:select.option value="">All statuses</flux:select.option>
|
||||
<flux:select.option value="latest">Latest</flux:select.option>
|
||||
<flux:select.option value="active">Active (non-latest)</flux:select.option>
|
||||
<flux:select.option value="deprecated">Deprecated</flux:select.option>
|
||||
<flux:select.option value="sunset">Sunset</flux:select.option>
|
||||
</flux:select>
|
||||
@if($search || $server || $status)
|
||||
<flux:button wire:click="clearFilters" variant="ghost" size="sm" icon="x-mark">Clear</flux:button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Versions Table --}}
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>Tool</flux:table.column>
|
||||
<flux:table.column>Server</flux:table.column>
|
||||
<flux:table.column>Version</flux:table.column>
|
||||
<flux:table.column>Status</flux:table.column>
|
||||
<flux:table.column>Deprecated</flux:table.column>
|
||||
<flux:table.column>Sunset</flux:table.column>
|
||||
<flux:table.column>Created</flux:table.column>
|
||||
<flux:table.column></flux:table.column>
|
||||
</flux:table.columns>
|
||||
|
||||
<flux:table.rows>
|
||||
@forelse ($this->versions as $version)
|
||||
<flux:table.row wire:key="version-{{ $version->id }}">
|
||||
<flux:table.cell>
|
||||
<div class="font-medium text-zinc-900 dark:text-white">{{ $version->tool_name }}</div>
|
||||
@if($version->description)
|
||||
<div class="text-xs text-zinc-500 truncate max-w-xs">{{ $version->description }}</div>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{{ $version->server_id }}
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<code class="rounded bg-zinc-100 px-2 py-1 text-sm font-mono dark:bg-zinc-800">
|
||||
{{ $version->version }}
|
||||
</code>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:badge size="sm" :color="$this->getStatusBadgeColor($version->status)">
|
||||
{{ ucfirst($version->status) }}
|
||||
</flux:badge>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="text-sm text-zinc-500">
|
||||
@if($version->deprecated_at)
|
||||
{{ $version->deprecated_at->format('M j, Y') }}
|
||||
@else
|
||||
<span class="text-zinc-400">-</span>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="text-sm text-zinc-500">
|
||||
@if($version->sunset_at)
|
||||
<span class="{{ $version->is_sunset ? 'text-red-600 dark:text-red-400' : '' }}">
|
||||
{{ $version->sunset_at->format('M j, Y') }}
|
||||
</span>
|
||||
@else
|
||||
<span class="text-zinc-400">-</span>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell class="text-sm text-zinc-500">
|
||||
{{ $version->created_at->format('M j, Y') }}
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:dropdown>
|
||||
<flux:button variant="ghost" size="xs" icon="ellipsis-horizontal" />
|
||||
<flux:menu>
|
||||
<flux:menu.item wire:click="viewVersion({{ $version->id }})" icon="eye">
|
||||
View Details
|
||||
</flux:menu.item>
|
||||
@if(!$version->is_latest && !$version->is_sunset)
|
||||
<flux:menu.item wire:click="markAsLatest({{ $version->id }})" icon="star">
|
||||
Mark as Latest
|
||||
</flux:menu.item>
|
||||
@endif
|
||||
@if(!$version->is_deprecated && !$version->is_sunset)
|
||||
<flux:menu.item wire:click="openDeprecateModal({{ $version->id }})" icon="archive-box">
|
||||
Deprecate
|
||||
</flux:menu.item>
|
||||
@endif
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@empty
|
||||
<flux:table.row>
|
||||
<flux:table.cell colspan="8">
|
||||
<div class="flex flex-col items-center py-12">
|
||||
<div class="w-16 h-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mb-4">
|
||||
<flux:icon name="cube" class="size-8 text-zinc-400" />
|
||||
</div>
|
||||
<flux:heading size="lg">No tool versions found</flux:heading>
|
||||
<flux:subheading class="mt-1">Register tool versions to enable backwards compatibility.</flux:subheading>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforelse
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
|
||||
@if($this->versions->hasPages())
|
||||
<div class="border-t border-zinc-200 px-6 py-4 dark:border-zinc-700">
|
||||
{{ $this->versions->links() }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Version Detail Modal --}}
|
||||
@if($showVersionDetail && $this->selectedVersion)
|
||||
<flux:modal wire:model="showVersionDetail" name="version-detail" class="max-w-4xl">
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ $this->selectedVersion->tool_name }}</flux:heading>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<code class="rounded bg-zinc-100 px-2 py-1 text-sm font-mono dark:bg-zinc-800">
|
||||
{{ $this->selectedVersion->version }}
|
||||
</code>
|
||||
<flux:badge size="sm" :color="$this->getStatusBadgeColor($this->selectedVersion->status)">
|
||||
{{ ucfirst($this->selectedVersion->status) }}
|
||||
</flux:badge>
|
||||
</div>
|
||||
</div>
|
||||
<flux:button wire:click="closeVersionDetail" variant="ghost" icon="x-mark" />
|
||||
</div>
|
||||
|
||||
{{-- Metadata --}}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Server</div>
|
||||
<div class="mt-1">{{ $this->selectedVersion->server_id }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Created</div>
|
||||
<div class="mt-1">{{ $this->selectedVersion->created_at->format('Y-m-d H:i:s') }}</div>
|
||||
</div>
|
||||
@if($this->selectedVersion->deprecated_at)
|
||||
<div>
|
||||
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Deprecated</div>
|
||||
<div class="mt-1 text-amber-600 dark:text-amber-400">
|
||||
{{ $this->selectedVersion->deprecated_at->format('Y-m-d') }}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@if($this->selectedVersion->sunset_at)
|
||||
<div>
|
||||
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Sunset</div>
|
||||
<div class="mt-1 {{ $this->selectedVersion->is_sunset ? 'text-red-600 dark:text-red-400' : 'text-zinc-600' }}">
|
||||
{{ $this->selectedVersion->sunset_at->format('Y-m-d') }}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if($this->selectedVersion->description)
|
||||
<div>
|
||||
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Description</div>
|
||||
<div class="mt-1">{{ $this->selectedVersion->description }}</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($this->selectedVersion->changelog)
|
||||
<div>
|
||||
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Changelog</div>
|
||||
<div class="mt-1 prose prose-sm dark:prose-invert">
|
||||
{!! nl2br(e($this->selectedVersion->changelog)) !!}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($this->selectedVersion->migration_notes)
|
||||
<div class="rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
|
||||
<div class="flex items-center gap-2 text-blue-700 dark:text-blue-300">
|
||||
<flux:icon name="arrow-path" class="size-5" />
|
||||
<span class="font-medium">Migration Notes</span>
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-blue-600 dark:text-blue-400">
|
||||
{!! nl2br(e($this->selectedVersion->migration_notes)) !!}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Input Schema --}}
|
||||
@if($this->selectedVersion->input_schema)
|
||||
<div>
|
||||
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400 mb-2">Input Schema</div>
|
||||
<pre class="overflow-auto rounded-lg bg-zinc-100 p-4 text-xs dark:bg-zinc-800 max-h-60">{{ $this->formatSchema($this->selectedVersion->input_schema) }}</pre>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Output Schema --}}
|
||||
@if($this->selectedVersion->output_schema)
|
||||
<div>
|
||||
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400 mb-2">Output Schema</div>
|
||||
<pre class="overflow-auto rounded-lg bg-zinc-100 p-4 text-xs dark:bg-zinc-800 max-h-60">{{ $this->formatSchema($this->selectedVersion->output_schema) }}</pre>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Version History --}}
|
||||
@if($this->versionHistory->count() > 1)
|
||||
<div class="border-t border-zinc-200 pt-4 dark:border-zinc-700">
|
||||
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400 mb-3">Version History</div>
|
||||
<div class="space-y-2">
|
||||
@foreach($this->versionHistory as $index => $historyVersion)
|
||||
<div class="flex items-center justify-between rounded-lg border border-zinc-200 p-3 dark:border-zinc-700 {{ $historyVersion->id === $this->selectedVersion->id ? 'bg-zinc-50 dark:bg-zinc-800/50' : '' }}">
|
||||
<div class="flex items-center gap-3">
|
||||
<code class="rounded bg-zinc-100 px-2 py-1 text-sm font-mono dark:bg-zinc-800">
|
||||
{{ $historyVersion->version }}
|
||||
</code>
|
||||
<flux:badge size="sm" :color="$this->getStatusBadgeColor($historyVersion->status)">
|
||||
{{ ucfirst($historyVersion->status) }}
|
||||
</flux:badge>
|
||||
<span class="text-xs text-zinc-500">
|
||||
{{ $historyVersion->created_at->format('M j, Y') }}
|
||||
</span>
|
||||
</div>
|
||||
@if($historyVersion->id !== $this->selectedVersion->id && $index < $this->versionHistory->count() - 1)
|
||||
@php $nextVersion = $this->versionHistory[$index + 1] @endphp
|
||||
<flux:button
|
||||
wire:click="openCompareModal({{ $nextVersion->id }}, {{ $historyVersion->id }})"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
icon="arrows-right-left"
|
||||
>
|
||||
Compare
|
||||
</flux:button>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</flux:modal>
|
||||
@endif
|
||||
|
||||
{{-- Compare Schemas Modal --}}
|
||||
@if($showCompareModal && $this->schemaComparison)
|
||||
<flux:modal wire:model="showCompareModal" name="compare-modal" class="max-w-4xl">
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<flux:heading size="lg">Schema Comparison</flux:heading>
|
||||
<flux:button wire:click="closeCompareModal" variant="ghost" icon="x-mark" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center gap-4">
|
||||
<div class="text-center">
|
||||
<code class="rounded bg-zinc-100 px-3 py-1 text-sm font-mono dark:bg-zinc-800">
|
||||
{{ $this->schemaComparison['from']->version }}
|
||||
</code>
|
||||
</div>
|
||||
<flux:icon name="arrow-right" class="size-5 text-zinc-400" />
|
||||
<div class="text-center">
|
||||
<code class="rounded bg-zinc-100 px-3 py-1 text-sm font-mono dark:bg-zinc-800">
|
||||
{{ $this->schemaComparison['to']->version }}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@php $changes = $this->schemaComparison['changes'] @endphp
|
||||
|
||||
@if(empty($changes['added']) && empty($changes['removed']) && empty($changes['changed']))
|
||||
<div class="rounded-lg bg-green-50 p-4 dark:bg-green-900/20">
|
||||
<div class="flex items-center gap-2 text-green-700 dark:text-green-300">
|
||||
<flux:icon name="check-circle" class="size-5" />
|
||||
<span>No schema changes between versions</span>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-4">
|
||||
@if(!empty($changes['added']))
|
||||
<div class="rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-800 dark:bg-green-900/20">
|
||||
<div class="font-medium text-green-700 dark:text-green-300 mb-2">
|
||||
Added Properties ({{ count($changes['added']) }})
|
||||
</div>
|
||||
<ul class="list-disc list-inside text-sm text-green-600 dark:text-green-400">
|
||||
@foreach($changes['added'] as $prop)
|
||||
<li><code>{{ $prop }}</code></li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(!empty($changes['removed']))
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20">
|
||||
<div class="font-medium text-red-700 dark:text-red-300 mb-2">
|
||||
Removed Properties ({{ count($changes['removed']) }})
|
||||
</div>
|
||||
<ul class="list-disc list-inside text-sm text-red-600 dark:text-red-400">
|
||||
@foreach($changes['removed'] as $prop)
|
||||
<li><code>{{ $prop }}</code></li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(!empty($changes['changed']))
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20">
|
||||
<div class="font-medium text-amber-700 dark:text-amber-300 mb-2">
|
||||
Changed Properties ({{ count($changes['changed']) }})
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
@foreach($changes['changed'] as $prop => $change)
|
||||
<div class="text-sm">
|
||||
<code class="font-medium text-amber-700 dark:text-amber-300">{{ $prop }}</code>
|
||||
<div class="mt-1 grid grid-cols-2 gap-2 text-xs">
|
||||
<div class="rounded bg-red-100 p-2 dark:bg-red-900/30">
|
||||
<div class="text-red-600 dark:text-red-400 mb-1">Before:</div>
|
||||
<pre class="text-red-700 dark:text-red-300 overflow-auto">{{ json_encode($change['from'], JSON_PRETTY_PRINT) }}</pre>
|
||||
</div>
|
||||
<div class="rounded bg-green-100 p-2 dark:bg-green-900/30">
|
||||
<div class="text-green-600 dark:text-green-400 mb-1">After:</div>
|
||||
<pre class="text-green-700 dark:text-green-300 overflow-auto">{{ json_encode($change['to'], JSON_PRETTY_PRINT) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex justify-end">
|
||||
<flux:button wire:click="closeCompareModal" variant="primary">Close</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:modal>
|
||||
@endif
|
||||
|
||||
{{-- Deprecate Modal --}}
|
||||
@if($showDeprecateModal)
|
||||
@php $deprecateVersion = \Core\Mod\Mcp\Models\McpToolVersion::find($deprecateVersionId) @endphp
|
||||
@if($deprecateVersion)
|
||||
<flux:modal wire:model="showDeprecateModal" name="deprecate-modal" class="max-w-md">
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<flux:heading size="lg">Deprecate Version</flux:heading>
|
||||
<flux:button wire:click="closeDeprecateModal" variant="ghost" icon="x-mark" />
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-amber-50 p-4 dark:bg-amber-900/20">
|
||||
<div class="flex items-center gap-2 text-amber-700 dark:text-amber-300">
|
||||
<flux:icon name="exclamation-triangle" class="size-5" />
|
||||
<span class="font-medium">{{ $deprecateVersion->tool_name }} v{{ $deprecateVersion->version }}</span>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-amber-600 dark:text-amber-400">
|
||||
Deprecated versions will show warnings to agents but remain usable until sunset.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<flux:label>Sunset Date (optional)</flux:label>
|
||||
<flux:input
|
||||
type="date"
|
||||
wire:model="deprecateSunsetDate"
|
||||
:min="now()->addDay()->format('Y-m-d')"
|
||||
/>
|
||||
<flux:description class="mt-1">
|
||||
After this date, the version will be blocked and return errors.
|
||||
</flux:description>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<flux:button wire:click="closeDeprecateModal" variant="ghost">Cancel</flux:button>
|
||||
<flux:button wire:click="deprecateVersion" variant="primary" color="amber">
|
||||
Deprecate Version
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:modal>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
{{-- Register Version Modal --}}
|
||||
@if($showRegisterModal)
|
||||
<flux:modal wire:model="showRegisterModal" name="register-modal" class="max-w-2xl">
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<flux:heading size="lg">Register Tool Version</flux:heading>
|
||||
<flux:button wire:click="closeRegisterModal" variant="ghost" icon="x-mark" />
|
||||
</div>
|
||||
|
||||
<form wire:submit="registerVersion" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<flux:label>Server ID</flux:label>
|
||||
<flux:input
|
||||
wire:model="registerServer"
|
||||
placeholder="e.g., hub-agent"
|
||||
required
|
||||
/>
|
||||
@error('registerServer') <flux:error>{{ $message }}</flux:error> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<flux:label>Tool Name</flux:label>
|
||||
<flux:input
|
||||
wire:model="registerTool"
|
||||
placeholder="e.g., query_database"
|
||||
required
|
||||
/>
|
||||
@error('registerTool') <flux:error>{{ $message }}</flux:error> @enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<flux:label>Version (semver)</flux:label>
|
||||
<flux:input
|
||||
wire:model="registerVersion"
|
||||
placeholder="1.0.0"
|
||||
required
|
||||
/>
|
||||
@error('registerVersion') <flux:error>{{ $message }}</flux:error> @enderror
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<flux:checkbox wire:model="registerMarkLatest" label="Mark as latest version" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<flux:label>Description</flux:label>
|
||||
<flux:input
|
||||
wire:model="registerDescription"
|
||||
placeholder="Brief description of the tool"
|
||||
/>
|
||||
@error('registerDescription') <flux:error>{{ $message }}</flux:error> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<flux:label>Changelog</flux:label>
|
||||
<flux:textarea
|
||||
wire:model="registerChangelog"
|
||||
placeholder="What changed in this version..."
|
||||
rows="3"
|
||||
/>
|
||||
@error('registerChangelog') <flux:error>{{ $message }}</flux:error> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<flux:label>Migration Notes</flux:label>
|
||||
<flux:textarea
|
||||
wire:model="registerMigrationNotes"
|
||||
placeholder="Guidance for upgrading from previous version..."
|
||||
rows="3"
|
||||
/>
|
||||
@error('registerMigrationNotes') <flux:error>{{ $message }}</flux:error> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<flux:label>Input Schema (JSON)</flux:label>
|
||||
<flux:textarea
|
||||
wire:model="registerInputSchema"
|
||||
placeholder='{"type": "object", "properties": {...}}'
|
||||
rows="6"
|
||||
class="font-mono text-sm"
|
||||
/>
|
||||
@error('registerInputSchema') <flux:error>{{ $message }}</flux:error> @enderror
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-4 border-t border-zinc-200 dark:border-zinc-700">
|
||||
<flux:button type="button" wire:click="closeRegisterModal" variant="ghost">Cancel</flux:button>
|
||||
<flux:button type="submit" variant="primary">Register Version</flux:button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</flux:modal>
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -0,0 +1,349 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Mcp\View\Modal\Admin;
|
||||
|
||||
use Core\Mod\Mcp\Models\McpToolVersion;
|
||||
use Core\Mod\Mcp\Services\ToolVersionService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
/**
|
||||
* MCP Tool Version Manager.
|
||||
*
|
||||
* Admin interface for managing tool version lifecycles,
|
||||
* viewing schema changes, and setting deprecation schedules.
|
||||
*/
|
||||
#[Title('Tool Versions')]
|
||||
#[Layout('hub::admin.layouts.app')]
|
||||
class ToolVersionManager extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
#[Url]
|
||||
public string $search = '';
|
||||
|
||||
#[Url]
|
||||
public string $server = '';
|
||||
|
||||
#[Url]
|
||||
public string $status = '';
|
||||
|
||||
public int $perPage = 25;
|
||||
|
||||
// Modal state
|
||||
public bool $showVersionDetail = false;
|
||||
|
||||
public ?int $selectedVersionId = null;
|
||||
|
||||
public bool $showCompareModal = false;
|
||||
|
||||
public ?int $compareFromId = null;
|
||||
|
||||
public ?int $compareToId = null;
|
||||
|
||||
public bool $showDeprecateModal = false;
|
||||
|
||||
public ?int $deprecateVersionId = null;
|
||||
|
||||
public string $deprecateSunsetDate = '';
|
||||
|
||||
public bool $showRegisterModal = false;
|
||||
|
||||
public string $registerServer = '';
|
||||
|
||||
public string $registerTool = '';
|
||||
|
||||
public string $registerVersion = '';
|
||||
|
||||
public string $registerDescription = '';
|
||||
|
||||
public string $registerChangelog = '';
|
||||
|
||||
public string $registerMigrationNotes = '';
|
||||
|
||||
public string $registerInputSchema = '';
|
||||
|
||||
public bool $registerMarkLatest = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->checkHadesAccess();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function versions(): LengthAwarePaginator
|
||||
{
|
||||
$query = McpToolVersion::query()
|
||||
->orderByDesc('created_at');
|
||||
|
||||
if ($this->search) {
|
||||
$query->where(function ($q) {
|
||||
$q->where('tool_name', 'like', "%{$this->search}%")
|
||||
->orWhere('server_id', 'like', "%{$this->search}%")
|
||||
->orWhere('version', 'like', "%{$this->search}%")
|
||||
->orWhere('description', 'like', "%{$this->search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($this->server) {
|
||||
$query->forServer($this->server);
|
||||
}
|
||||
|
||||
if ($this->status === 'latest') {
|
||||
$query->latest();
|
||||
} elseif ($this->status === 'deprecated') {
|
||||
$query->deprecated();
|
||||
} elseif ($this->status === 'sunset') {
|
||||
$query->sunset();
|
||||
} elseif ($this->status === 'active') {
|
||||
$query->active()->where('is_latest', false);
|
||||
}
|
||||
|
||||
return $query->paginate($this->perPage);
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function servers(): Collection
|
||||
{
|
||||
return app(ToolVersionService::class)->getServersWithVersions();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function stats(): array
|
||||
{
|
||||
return app(ToolVersionService::class)->getStats();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function selectedVersion(): ?McpToolVersion
|
||||
{
|
||||
if (! $this->selectedVersionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return McpToolVersion::find($this->selectedVersionId);
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function versionHistory(): Collection
|
||||
{
|
||||
if (! $this->selectedVersion) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return app(ToolVersionService::class)->getVersionHistory(
|
||||
$this->selectedVersion->server_id,
|
||||
$this->selectedVersion->tool_name
|
||||
);
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function schemaComparison(): ?array
|
||||
{
|
||||
if (! $this->compareFromId || ! $this->compareToId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$from = McpToolVersion::find($this->compareFromId);
|
||||
$to = McpToolVersion::find($this->compareToId);
|
||||
|
||||
if (! $from || ! $to) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'changes' => $from->compareSchemaWith($to),
|
||||
];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Actions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function viewVersion(int $id): void
|
||||
{
|
||||
$this->selectedVersionId = $id;
|
||||
$this->showVersionDetail = true;
|
||||
}
|
||||
|
||||
public function closeVersionDetail(): void
|
||||
{
|
||||
$this->showVersionDetail = false;
|
||||
$this->selectedVersionId = null;
|
||||
}
|
||||
|
||||
public function openCompareModal(int $fromId, int $toId): void
|
||||
{
|
||||
$this->compareFromId = $fromId;
|
||||
$this->compareToId = $toId;
|
||||
$this->showCompareModal = true;
|
||||
}
|
||||
|
||||
public function closeCompareModal(): void
|
||||
{
|
||||
$this->showCompareModal = false;
|
||||
$this->compareFromId = null;
|
||||
$this->compareToId = null;
|
||||
}
|
||||
|
||||
public function openDeprecateModal(int $versionId): void
|
||||
{
|
||||
$this->deprecateVersionId = $versionId;
|
||||
$this->deprecateSunsetDate = '';
|
||||
$this->showDeprecateModal = true;
|
||||
}
|
||||
|
||||
public function closeDeprecateModal(): void
|
||||
{
|
||||
$this->showDeprecateModal = false;
|
||||
$this->deprecateVersionId = null;
|
||||
$this->deprecateSunsetDate = '';
|
||||
}
|
||||
|
||||
public function deprecateVersion(): void
|
||||
{
|
||||
$version = McpToolVersion::find($this->deprecateVersionId);
|
||||
if (! $version) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sunsetAt = $this->deprecateSunsetDate
|
||||
? Carbon::parse($this->deprecateSunsetDate)
|
||||
: null;
|
||||
|
||||
app(ToolVersionService::class)->deprecateVersion(
|
||||
$version->server_id,
|
||||
$version->tool_name,
|
||||
$version->version,
|
||||
$sunsetAt
|
||||
);
|
||||
|
||||
$this->closeDeprecateModal();
|
||||
$this->dispatch('version-deprecated');
|
||||
}
|
||||
|
||||
public function markAsLatest(int $versionId): void
|
||||
{
|
||||
$version = McpToolVersion::find($versionId);
|
||||
if (! $version) {
|
||||
return;
|
||||
}
|
||||
|
||||
$version->markAsLatest();
|
||||
$this->dispatch('version-marked-latest');
|
||||
}
|
||||
|
||||
public function openRegisterModal(): void
|
||||
{
|
||||
$this->resetRegisterForm();
|
||||
$this->showRegisterModal = true;
|
||||
}
|
||||
|
||||
public function closeRegisterModal(): void
|
||||
{
|
||||
$this->showRegisterModal = false;
|
||||
$this->resetRegisterForm();
|
||||
}
|
||||
|
||||
public function registerVersion(): void
|
||||
{
|
||||
$this->validate([
|
||||
'registerServer' => 'required|string|max:64',
|
||||
'registerTool' => 'required|string|max:128',
|
||||
'registerVersion' => 'required|string|max:32|regex:/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?$/',
|
||||
'registerDescription' => 'nullable|string|max:1000',
|
||||
'registerChangelog' => 'nullable|string|max:5000',
|
||||
'registerMigrationNotes' => 'nullable|string|max:5000',
|
||||
'registerInputSchema' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$inputSchema = null;
|
||||
if ($this->registerInputSchema) {
|
||||
$inputSchema = json_decode($this->registerInputSchema, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$this->addError('registerInputSchema', 'Invalid JSON');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
app(ToolVersionService::class)->registerVersion(
|
||||
serverId: $this->registerServer,
|
||||
toolName: $this->registerTool,
|
||||
version: $this->registerVersion,
|
||||
inputSchema: $inputSchema,
|
||||
description: $this->registerDescription ?: null,
|
||||
options: [
|
||||
'changelog' => $this->registerChangelog ?: null,
|
||||
'migration_notes' => $this->registerMigrationNotes ?: null,
|
||||
'mark_latest' => $this->registerMarkLatest,
|
||||
]
|
||||
);
|
||||
|
||||
$this->closeRegisterModal();
|
||||
$this->dispatch('version-registered');
|
||||
}
|
||||
|
||||
public function clearFilters(): void
|
||||
{
|
||||
$this->search = '';
|
||||
$this->server = '';
|
||||
$this->status = '';
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function getStatusBadgeColor(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'latest' => 'green',
|
||||
'active' => 'zinc',
|
||||
'deprecated' => 'amber',
|
||||
'sunset' => 'red',
|
||||
default => 'zinc',
|
||||
};
|
||||
}
|
||||
|
||||
public function formatSchema(array $schema): string
|
||||
{
|
||||
return json_encode($schema, JSON_PRETTY_PRINT);
|
||||
}
|
||||
|
||||
private function resetRegisterForm(): void
|
||||
{
|
||||
$this->registerServer = '';
|
||||
$this->registerTool = '';
|
||||
$this->registerVersion = '';
|
||||
$this->registerDescription = '';
|
||||
$this->registerChangelog = '';
|
||||
$this->registerMigrationNotes = '';
|
||||
$this->registerInputSchema = '';
|
||||
$this->registerMarkLatest = false;
|
||||
}
|
||||
|
||||
private function checkHadesAccess(): void
|
||||
{
|
||||
if (! auth()->user()?->isHades()) {
|
||||
abort(403, 'Hades access required');
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('mcp::admin.tool-version-manager');
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
"authors": [
|
||||
{
|
||||
"name": "Host UK",
|
||||
"email": "dev@host.uk.com"
|
||||
"email": "support@host.uk.com"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ namespace Core\Headers;
|
|||
* </script>
|
||||
*
|
||||
* <style nonce="{{ csp_nonce() }}">
|
||||
* /* Your inline CSS */
|
||||
* // Your inline CSS
|
||||
* </style>
|
||||
* ```
|
||||
*
|
||||
|
|
|
|||
|
|
@ -249,7 +249,7 @@ Add warning comment explaining DEBUG_TOKEN should never be committed.
|
|||
**File:** `composer.json`
|
||||
|
||||
**Issue:**
|
||||
Author email is company-specific: `dev@host.uk.com`
|
||||
Author email is company-specific: `support@host.uk.com`
|
||||
|
||||
**Suggested Fix:**
|
||||
Update to generic project contact or maintainer email.
|
||||
|
|
|
|||
|
|
@ -79,6 +79,11 @@ class Boot extends ServiceProvider
|
|||
\Core\Mod\Tenant\Services\EntitlementWebhookService::class
|
||||
);
|
||||
|
||||
$this->app->singleton(
|
||||
\Core\Mod\Tenant\Services\WorkspaceTeamService::class,
|
||||
\Core\Mod\Tenant\Services\WorkspaceTeamService::class
|
||||
);
|
||||
|
||||
$this->registerBackwardCompatAliases();
|
||||
}
|
||||
|
||||
|
|
@ -157,6 +162,7 @@ class Boot extends ServiceProvider
|
|||
public function onConsole(ConsoleBooting $event): void
|
||||
{
|
||||
$event->middleware('admin.domain', Middleware\RequireAdminDomain::class);
|
||||
$event->middleware('workspace.permission', Middleware\CheckWorkspacePermission::class);
|
||||
|
||||
// Artisan commands
|
||||
$event->command(Console\Commands\RefreshUserStats::class);
|
||||
|
|
|
|||
|
|
@ -298,6 +298,228 @@ return [
|
|||
'usage_reset' => 'Usage counters have been reset for the new billing period.',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Common
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'common' => [
|
||||
'na' => 'N/A',
|
||||
'none' => 'None',
|
||||
'unknown' => 'Unknown',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Errors
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'errors' => [
|
||||
'hades_required' => 'Hades tier required for this feature.',
|
||||
'unauthenticated' => 'You must be logged in to access this resource.',
|
||||
'no_workspace' => 'No workspace context available.',
|
||||
'insufficient_permissions' => 'You do not have permission to perform this action.',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Admin - Team Manager
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'admin' => [
|
||||
// ... existing admin translations will be merged ...
|
||||
|
||||
'team_manager' => [
|
||||
'title' => 'Workspace Teams',
|
||||
'subtitle' => 'Manage teams and role-based permissions for workspaces',
|
||||
|
||||
'stats' => [
|
||||
'total_teams' => 'Total Teams',
|
||||
'total_members' => 'Total Members',
|
||||
'members_assigned' => 'Assigned to Teams',
|
||||
],
|
||||
|
||||
'search' => [
|
||||
'placeholder' => 'Search teams by name...',
|
||||
],
|
||||
|
||||
'filter' => [
|
||||
'all_workspaces' => 'All Workspaces',
|
||||
],
|
||||
|
||||
'columns' => [
|
||||
'team' => 'Team',
|
||||
'workspace' => 'Workspace',
|
||||
'members' => 'Members',
|
||||
'permissions' => 'Permissions',
|
||||
'actions' => 'Actions',
|
||||
],
|
||||
|
||||
'labels' => [
|
||||
'permissions' => 'permissions',
|
||||
],
|
||||
|
||||
'badges' => [
|
||||
'system' => 'System',
|
||||
'default' => 'Default',
|
||||
],
|
||||
|
||||
'actions' => [
|
||||
'create_team' => 'Create Team',
|
||||
'edit' => 'Edit',
|
||||
'delete' => 'Delete',
|
||||
'view_members' => 'View Members',
|
||||
'seed_defaults' => 'Seed Defaults',
|
||||
'migrate_members' => 'Migrate Members',
|
||||
],
|
||||
|
||||
'confirm' => [
|
||||
'delete_team' => 'Are you sure you want to delete this team? Members will be unassigned.',
|
||||
],
|
||||
|
||||
'empty_state' => [
|
||||
'title' => 'No teams found',
|
||||
'description' => 'Create teams to organise members and control permissions in your workspaces.',
|
||||
],
|
||||
|
||||
'modal' => [
|
||||
'title_create' => 'Create Team',
|
||||
'title_edit' => 'Edit Team',
|
||||
|
||||
'fields' => [
|
||||
'workspace' => 'Workspace',
|
||||
'select_workspace' => 'Select workspace...',
|
||||
'name' => 'Name',
|
||||
'name_placeholder' => 'e.g. Editors',
|
||||
'slug' => 'Slug',
|
||||
'slug_placeholder' => 'e.g. editors',
|
||||
'slug_description' => 'Leave blank to auto-generate from name.',
|
||||
'description' => 'Description',
|
||||
'colour' => 'Colour',
|
||||
'is_default' => 'Default team for new members',
|
||||
'permissions' => 'Permissions',
|
||||
],
|
||||
|
||||
'actions' => [
|
||||
'cancel' => 'Cancel',
|
||||
'create' => 'Create Team',
|
||||
'update' => 'Update Team',
|
||||
],
|
||||
],
|
||||
|
||||
'messages' => [
|
||||
'team_created' => 'Team created successfully.',
|
||||
'team_updated' => 'Team updated successfully.',
|
||||
'team_deleted' => 'Team deleted successfully.',
|
||||
'cannot_delete_system' => 'Cannot delete system teams.',
|
||||
'cannot_delete_has_members' => 'Cannot delete team with :count assigned member(s). Remove members first.',
|
||||
'defaults_seeded' => 'Default teams have been seeded successfully.',
|
||||
'members_migrated' => ':count member(s) have been migrated to teams.',
|
||||
],
|
||||
],
|
||||
|
||||
'member_manager' => [
|
||||
'title' => 'Workspace Members',
|
||||
'subtitle' => 'Manage member team assignments and custom permissions',
|
||||
|
||||
'stats' => [
|
||||
'total_members' => 'Total Members',
|
||||
'with_team' => 'Assigned to Team',
|
||||
'with_custom' => 'With Custom Permissions',
|
||||
],
|
||||
|
||||
'search' => [
|
||||
'placeholder' => 'Search members by name or email...',
|
||||
],
|
||||
|
||||
'filter' => [
|
||||
'all_workspaces' => 'All Workspaces',
|
||||
'all_teams' => 'All Teams',
|
||||
],
|
||||
|
||||
'columns' => [
|
||||
'member' => 'Member',
|
||||
'workspace' => 'Workspace',
|
||||
'team' => 'Team',
|
||||
'role' => 'Legacy Role',
|
||||
'permissions' => 'Custom',
|
||||
'actions' => 'Actions',
|
||||
],
|
||||
|
||||
'labels' => [
|
||||
'no_team' => 'No team',
|
||||
'inherited' => 'Inherited',
|
||||
],
|
||||
|
||||
'actions' => [
|
||||
'assign_team' => 'Assign to Team',
|
||||
'remove_from_team' => 'Remove from Team',
|
||||
'custom_permissions' => 'Custom Permissions',
|
||||
'clear_permissions' => 'Clear Custom Permissions',
|
||||
],
|
||||
|
||||
'confirm' => [
|
||||
'clear_permissions' => 'Are you sure you want to clear all custom permissions for this member?',
|
||||
'bulk_remove_team' => 'Are you sure you want to remove the selected members from their teams?',
|
||||
'bulk_clear_permissions' => 'Are you sure you want to clear custom permissions for all selected members?',
|
||||
],
|
||||
|
||||
'bulk' => [
|
||||
'selected' => ':count selected',
|
||||
'assign_team' => 'Assign Team',
|
||||
'remove_team' => 'Remove Team',
|
||||
'clear_permissions' => 'Clear Permissions',
|
||||
'clear' => 'Clear',
|
||||
],
|
||||
|
||||
'empty_state' => [
|
||||
'title' => 'No members found',
|
||||
'description' => 'No members match your current filter criteria.',
|
||||
],
|
||||
|
||||
'modal' => [
|
||||
'actions' => [
|
||||
'cancel' => 'Cancel',
|
||||
'save' => 'Save',
|
||||
'assign' => 'Assign',
|
||||
],
|
||||
],
|
||||
|
||||
'assign_modal' => [
|
||||
'title' => 'Assign to Team',
|
||||
'team' => 'Team',
|
||||
'no_team' => 'No team (remove assignment)',
|
||||
],
|
||||
|
||||
'permissions_modal' => [
|
||||
'title' => 'Custom Permissions',
|
||||
'team_permissions' => 'Team: :team',
|
||||
'description' => 'Custom permissions override the team permissions. Grant additional permissions or revoke specific ones.',
|
||||
'grant_label' => 'Grant Additional Permissions',
|
||||
'revoke_label' => 'Revoke Permissions',
|
||||
],
|
||||
|
||||
'bulk_assign_modal' => [
|
||||
'title' => 'Bulk Assign Team',
|
||||
'description' => 'Assign :count selected member(s) to a team.',
|
||||
'team' => 'Team',
|
||||
'no_team' => 'No team (remove assignment)',
|
||||
],
|
||||
|
||||
'messages' => [
|
||||
'team_assigned' => 'Member assigned to team successfully.',
|
||||
'removed_from_team' => 'Member removed from team successfully.',
|
||||
'permissions_updated' => 'Custom permissions updated successfully.',
|
||||
'permissions_cleared' => 'Custom permissions cleared successfully.',
|
||||
'no_members_selected' => 'No members selected.',
|
||||
'invalid_team' => 'Invalid team selected.',
|
||||
'bulk_team_assigned' => ':count member(s) assigned to team.',
|
||||
'bulk_removed_from_team' => ':count member(s) removed from team.',
|
||||
'bulk_permissions_cleared' => 'Custom permissions cleared for :count member(s).',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Entitlement Webhooks
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
use Core\Mod\Tenant\Services\WorkspaceTeamService;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Middleware to check if the current user has a specific workspace permission.
|
||||
*
|
||||
* Usage in routes:
|
||||
* Route::middleware('workspace.permission:bio.write')
|
||||
* Route::middleware('workspace.permission:workspace.manage_settings,workspace.manage_members')
|
||||
*
|
||||
* The middleware checks if the user has ANY of the specified permissions (OR logic).
|
||||
* Use multiple middleware definitions for AND logic.
|
||||
*/
|
||||
class CheckWorkspacePermission
|
||||
{
|
||||
public function __construct(
|
||||
protected WorkspaceTeamService $teamService
|
||||
) {}
|
||||
|
||||
public function handle(Request $request, Closure $next, string ...$permissions): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user) {
|
||||
abort(403, __('tenant::tenant.errors.unauthenticated'));
|
||||
}
|
||||
|
||||
// Get current workspace from request or user's default
|
||||
$workspace = $this->getWorkspace($request);
|
||||
|
||||
if (! $workspace) {
|
||||
abort(403, __('tenant::tenant.errors.no_workspace'));
|
||||
}
|
||||
|
||||
// Set up the team service with the workspace context
|
||||
$this->teamService->forWorkspace($workspace);
|
||||
|
||||
// Check if user has any of the required permissions
|
||||
if (! $this->teamService->hasAnyPermission($user, $permissions)) {
|
||||
abort(403, __('tenant::tenant.errors.insufficient_permissions'));
|
||||
}
|
||||
|
||||
// Store the workspace and member in request for later use
|
||||
$request->attributes->set('workspace_model', $workspace);
|
||||
|
||||
$member = $this->teamService->getMember($user);
|
||||
if ($member) {
|
||||
$request->attributes->set('workspace_member', $member);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
protected function getWorkspace(Request $request): ?Workspace
|
||||
{
|
||||
// First try to get from request attributes (already resolved by other middleware)
|
||||
if ($request->attributes->has('workspace_model')) {
|
||||
return $request->attributes->get('workspace_model');
|
||||
}
|
||||
|
||||
// Try to get from route parameter
|
||||
$workspaceParam = $request->route('workspace');
|
||||
if ($workspaceParam instanceof Workspace) {
|
||||
return $workspaceParam;
|
||||
}
|
||||
|
||||
if (is_string($workspaceParam) || is_int($workspaceParam)) {
|
||||
return Workspace::where('slug', $workspaceParam)
|
||||
->orWhere('id', $workspaceParam)
|
||||
->first();
|
||||
}
|
||||
|
||||
// Try to get from session
|
||||
$sessionSlug = session('workspace');
|
||||
if ($sessionSlug) {
|
||||
return Workspace::where('slug', $sessionSlug)->first();
|
||||
}
|
||||
|
||||
// Fall back to user's default workspace
|
||||
$user = $request->user();
|
||||
if ($user && method_exists($user, 'defaultHostWorkspace')) {
|
||||
return $user->defaultHostWorkspace();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Workspace teams and enhanced member pivot for role-based access control.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// 1. Create workspace teams table
|
||||
Schema::create('workspace_teams', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->string('slug');
|
||||
$table->text('description')->nullable();
|
||||
$table->json('permissions')->nullable();
|
||||
$table->boolean('is_default')->default(false);
|
||||
$table->boolean('is_system')->default(false);
|
||||
$table->string('colour', 32)->default('zinc');
|
||||
$table->integer('sort_order')->default(0);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['workspace_id', 'slug']);
|
||||
$table->index(['workspace_id', 'is_default']);
|
||||
});
|
||||
|
||||
// 2. Enhance user_workspace pivot table
|
||||
Schema::table('user_workspace', function (Blueprint $table) {
|
||||
$table->foreignId('team_id')->nullable()
|
||||
->after('role')
|
||||
->constrained('workspace_teams')
|
||||
->nullOnDelete();
|
||||
$table->json('custom_permissions')->nullable()->after('team_id');
|
||||
$table->timestamp('joined_at')->nullable()->after('custom_permissions');
|
||||
$table->foreignId('invited_by')->nullable()
|
||||
->after('joined_at')
|
||||
->constrained('users')
|
||||
->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('user_workspace', function (Blueprint $table) {
|
||||
$table->dropForeign(['team_id']);
|
||||
$table->dropForeign(['invited_by']);
|
||||
$table->dropColumn(['team_id', 'custom_permissions', 'joined_at', 'invited_by']);
|
||||
});
|
||||
|
||||
Schema::dropIfExists('workspace_teams');
|
||||
}
|
||||
};
|
||||
|
|
@ -82,10 +82,26 @@ class Workspace extends Model
|
|||
public function users(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'user_workspace')
|
||||
->withPivot(['role', 'is_default'])
|
||||
->withPivot(['role', 'is_default', 'team_id', 'custom_permissions', 'joined_at', 'invited_by'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workspace members (via the enhanced pivot model).
|
||||
*/
|
||||
public function members(): HasMany
|
||||
{
|
||||
return $this->hasMany(WorkspaceMember::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get teams defined for this workspace.
|
||||
*/
|
||||
public function teams(): HasMany
|
||||
{
|
||||
return $this->hasMany(WorkspaceTeam::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the workspace owner (user with 'owner' role).
|
||||
*/
|
||||
|
|
@ -96,6 +112,14 @@ class Workspace extends Model
|
|||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default team for new members.
|
||||
*/
|
||||
public function defaultTeam(): ?WorkspaceTeam
|
||||
{
|
||||
return $this->teams()->where('is_default', true)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Active package assignments for this workspace.
|
||||
*/
|
||||
|
|
|
|||
377
packages/core-php/src/Mod/Tenant/Models/WorkspaceMember.php
Normal file
377
packages/core-php/src/Mod/Tenant/Models/WorkspaceMember.php
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Workspace Member - enhanced pivot model for user-workspace relationship.
|
||||
*
|
||||
* This model wraps the user_workspace pivot table to provide team-based
|
||||
* access control with custom permission overrides.
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $user_id
|
||||
* @property int $workspace_id
|
||||
* @property string $role
|
||||
* @property int|null $team_id
|
||||
* @property array|null $custom_permissions
|
||||
* @property bool $is_default
|
||||
* @property \Carbon\Carbon|null $joined_at
|
||||
* @property int|null $invited_by
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
*/
|
||||
class WorkspaceMember extends Model
|
||||
{
|
||||
protected $table = 'user_workspace';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'workspace_id',
|
||||
'role',
|
||||
'team_id',
|
||||
'custom_permissions',
|
||||
'is_default',
|
||||
'joined_at',
|
||||
'invited_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'custom_permissions' => 'array',
|
||||
'is_default' => 'boolean',
|
||||
'joined_at' => 'datetime',
|
||||
];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Role Constants (legacy, for backwards compatibility)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public const ROLE_OWNER = 'owner';
|
||||
|
||||
public const ROLE_ADMIN = 'admin';
|
||||
|
||||
public const ROLE_MEMBER = 'member';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Relationships
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the user for this membership.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the workspace for this membership.
|
||||
*/
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the team for this membership.
|
||||
*/
|
||||
public function team(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(WorkspaceTeam::class, 'team_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user who invited this member.
|
||||
*/
|
||||
public function inviter(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'invited_by');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Scopes
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scope to a specific workspace.
|
||||
*/
|
||||
public function scopeForWorkspace($query, Workspace|int $workspace)
|
||||
{
|
||||
$workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace;
|
||||
|
||||
return $query->where('workspace_id', $workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to a specific user.
|
||||
*/
|
||||
public function scopeForUser($query, User|int $user)
|
||||
{
|
||||
$userId = $user instanceof User ? $user->id : $user;
|
||||
|
||||
return $query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to members with a specific role.
|
||||
*/
|
||||
public function scopeWithRole($query, string $role)
|
||||
{
|
||||
return $query->where('role', $role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to members in a specific team.
|
||||
*/
|
||||
public function scopeInTeam($query, WorkspaceTeam|int $team)
|
||||
{
|
||||
$teamId = $team instanceof WorkspaceTeam ? $team->id : $team;
|
||||
|
||||
return $query->where('team_id', $teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to owners only.
|
||||
*/
|
||||
public function scopeOwners($query)
|
||||
{
|
||||
return $query->where('role', self::ROLE_OWNER);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Permission Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get all effective permissions for this member.
|
||||
*
|
||||
* Merges team permissions with custom permission overrides.
|
||||
*/
|
||||
public function getEffectivePermissions(): array
|
||||
{
|
||||
// Start with team permissions
|
||||
$permissions = $this->team?->permissions ?? [];
|
||||
|
||||
// Merge custom permissions (overrides)
|
||||
$customPermissions = $this->custom_permissions ?? [];
|
||||
|
||||
// Custom permissions can grant (+permission) or revoke (-permission)
|
||||
foreach ($customPermissions as $permission) {
|
||||
if (str_starts_with($permission, '-')) {
|
||||
// Remove permission
|
||||
$toRemove = substr($permission, 1);
|
||||
$permissions = array_values(array_filter(
|
||||
$permissions,
|
||||
fn ($p) => $p !== $toRemove
|
||||
));
|
||||
} elseif (str_starts_with($permission, '+')) {
|
||||
// Add permission (explicit add)
|
||||
$toAdd = substr($permission, 1);
|
||||
if (! in_array($toAdd, $permissions, true)) {
|
||||
$permissions[] = $toAdd;
|
||||
}
|
||||
} else {
|
||||
// Treat as add if no prefix
|
||||
if (! in_array($permission, $permissions, true)) {
|
||||
$permissions[] = $permission;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy fallback: if no team, derive from role
|
||||
if (! $this->team_id) {
|
||||
$rolePermissions = match ($this->role) {
|
||||
self::ROLE_OWNER => WorkspaceTeam::getDefaultPermissionsFor(WorkspaceTeam::TEAM_OWNER),
|
||||
self::ROLE_ADMIN => WorkspaceTeam::getDefaultPermissionsFor(WorkspaceTeam::TEAM_ADMIN),
|
||||
default => WorkspaceTeam::getDefaultPermissionsFor(WorkspaceTeam::TEAM_MEMBER),
|
||||
};
|
||||
$permissions = array_unique(array_merge($permissions, $rolePermissions));
|
||||
}
|
||||
|
||||
return array_values(array_unique($permissions));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this member has a specific permission.
|
||||
*/
|
||||
public function hasPermission(string $permission): bool
|
||||
{
|
||||
$permissions = $this->getEffectivePermissions();
|
||||
|
||||
// Check for exact match
|
||||
if (in_array($permission, $permissions, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for wildcard permissions
|
||||
foreach ($permissions as $perm) {
|
||||
if (str_ends_with($perm, '.*')) {
|
||||
$prefix = substr($perm, 0, -1);
|
||||
if (str_starts_with($permission, $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this member has any of the given permissions.
|
||||
*/
|
||||
public function hasAnyPermission(array $permissions): bool
|
||||
{
|
||||
foreach ($permissions as $permission) {
|
||||
if ($this->hasPermission($permission)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this member has all of the given permissions.
|
||||
*/
|
||||
public function hasAllPermissions(array $permissions): bool
|
||||
{
|
||||
foreach ($permissions as $permission) {
|
||||
if (! $this->hasPermission($permission)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a custom permission override.
|
||||
*/
|
||||
public function grantCustomPermission(string $permission): self
|
||||
{
|
||||
$custom = $this->custom_permissions ?? [];
|
||||
|
||||
// Remove any revocation of this permission
|
||||
$custom = array_filter($custom, fn ($p) => $p !== '-'.$permission);
|
||||
|
||||
// Add the permission if not already present
|
||||
if (! in_array($permission, $custom, true) && ! in_array('+'.$permission, $custom, true)) {
|
||||
$custom[] = '+'.$permission;
|
||||
}
|
||||
|
||||
$this->update(['custom_permissions' => array_values($custom)]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a permission via custom override.
|
||||
*/
|
||||
public function revokeCustomPermission(string $permission): self
|
||||
{
|
||||
$custom = $this->custom_permissions ?? [];
|
||||
|
||||
// Remove any grant of this permission
|
||||
$custom = array_filter($custom, fn ($p) => $p !== $permission && $p !== '+'.$permission);
|
||||
|
||||
// Add revocation
|
||||
if (! in_array('-'.$permission, $custom, true)) {
|
||||
$custom[] = '-'.$permission;
|
||||
}
|
||||
|
||||
$this->update(['custom_permissions' => array_values($custom)]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all custom permission overrides.
|
||||
*/
|
||||
public function clearCustomPermissions(): self
|
||||
{
|
||||
$this->update(['custom_permissions' => null]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helper Methods
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if this member is the workspace owner.
|
||||
*/
|
||||
public function isOwner(): bool
|
||||
{
|
||||
return $this->role === self::ROLE_OWNER
|
||||
|| $this->team?->slug === WorkspaceTeam::TEAM_OWNER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this member is an admin.
|
||||
*/
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->isOwner()
|
||||
|| $this->role === self::ROLE_ADMIN
|
||||
|| $this->team?->slug === WorkspaceTeam::TEAM_ADMIN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign this member to a team.
|
||||
*/
|
||||
public function assignToTeam(WorkspaceTeam|int $team): self
|
||||
{
|
||||
$teamId = $team instanceof WorkspaceTeam ? $team->id : $team;
|
||||
|
||||
$this->update(['team_id' => $teamId]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove this member from their team.
|
||||
*/
|
||||
public function removeFromTeam(): self
|
||||
{
|
||||
$this->update(['team_id' => null]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display name for this membership (team name or role).
|
||||
*/
|
||||
public function getDisplayRole(): string
|
||||
{
|
||||
if ($this->team) {
|
||||
return $this->team->name;
|
||||
}
|
||||
|
||||
return match ($this->role) {
|
||||
self::ROLE_OWNER => 'Owner',
|
||||
self::ROLE_ADMIN => 'Admin',
|
||||
default => 'Member',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the colour for this membership's role badge.
|
||||
*/
|
||||
public function getRoleColour(): string
|
||||
{
|
||||
if ($this->team) {
|
||||
return $this->team->colour;
|
||||
}
|
||||
|
||||
return match ($this->role) {
|
||||
self::ROLE_OWNER => 'violet',
|
||||
self::ROLE_ADMIN => 'blue',
|
||||
default => 'zinc',
|
||||
};
|
||||
}
|
||||
}
|
||||
517
packages/core-php/src/Mod/Tenant/Models/WorkspaceTeam.php
Normal file
517
packages/core-php/src/Mod/Tenant/Models/WorkspaceTeam.php
Normal file
|
|
@ -0,0 +1,517 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Models;
|
||||
|
||||
use Core\Mod\Tenant\Concerns\BelongsToWorkspace;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Workspace Team - defines permissions for members within a workspace.
|
||||
*
|
||||
* Teams provide role-based access control at the workspace level. Members
|
||||
* can belong to a team and optionally have custom permission overrides.
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $workspace_id
|
||||
* @property string $name
|
||||
* @property string $slug
|
||||
* @property string|null $description
|
||||
* @property array|null $permissions
|
||||
* @property bool $is_default
|
||||
* @property bool $is_system
|
||||
* @property string $colour
|
||||
* @property int $sort_order
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
*/
|
||||
class WorkspaceTeam extends Model
|
||||
{
|
||||
use BelongsToWorkspace;
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'workspace_teams';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Team Constants
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public const TEAM_OWNER = 'owner';
|
||||
|
||||
public const TEAM_ADMIN = 'admin';
|
||||
|
||||
public const TEAM_MEMBER = 'member';
|
||||
|
||||
public const TEAM_VIEWER = 'viewer';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Permission Constants - Workspace
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public const PERM_WORKSPACE_SETTINGS = 'workspace.manage_settings';
|
||||
|
||||
public const PERM_WORKSPACE_MEMBERS = 'workspace.manage_members';
|
||||
|
||||
public const PERM_WORKSPACE_BILLING = 'workspace.manage_billing';
|
||||
|
||||
public const PERM_WORKSPACE_TEAMS = 'workspace.manage_teams';
|
||||
|
||||
public const PERM_WORKSPACE_DELETE = 'workspace.delete';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Permission Constants - Products (generic pattern: [product].read/write/admin)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Bio service
|
||||
public const PERM_BIO_READ = 'bio.read';
|
||||
|
||||
public const PERM_BIO_WRITE = 'bio.write';
|
||||
|
||||
public const PERM_BIO_ADMIN = 'bio.admin';
|
||||
|
||||
// Social service
|
||||
public const PERM_SOCIAL_READ = 'social.read';
|
||||
|
||||
public const PERM_SOCIAL_WRITE = 'social.write';
|
||||
|
||||
public const PERM_SOCIAL_ADMIN = 'social.admin';
|
||||
|
||||
// Analytics service
|
||||
public const PERM_ANALYTICS_READ = 'analytics.read';
|
||||
|
||||
public const PERM_ANALYTICS_WRITE = 'analytics.write';
|
||||
|
||||
public const PERM_ANALYTICS_ADMIN = 'analytics.admin';
|
||||
|
||||
// Trust service
|
||||
public const PERM_TRUST_READ = 'trust.read';
|
||||
|
||||
public const PERM_TRUST_WRITE = 'trust.write';
|
||||
|
||||
public const PERM_TRUST_ADMIN = 'trust.admin';
|
||||
|
||||
// Notify service
|
||||
public const PERM_NOTIFY_READ = 'notify.read';
|
||||
|
||||
public const PERM_NOTIFY_WRITE = 'notify.write';
|
||||
|
||||
public const PERM_NOTIFY_ADMIN = 'notify.admin';
|
||||
|
||||
// Support service
|
||||
public const PERM_SUPPORT_READ = 'support.read';
|
||||
|
||||
public const PERM_SUPPORT_WRITE = 'support.write';
|
||||
|
||||
public const PERM_SUPPORT_ADMIN = 'support.admin';
|
||||
|
||||
// Commerce/billing
|
||||
public const PERM_COMMERCE_READ = 'commerce.read';
|
||||
|
||||
public const PERM_COMMERCE_WRITE = 'commerce.write';
|
||||
|
||||
public const PERM_COMMERCE_ADMIN = 'commerce.admin';
|
||||
|
||||
// API management
|
||||
public const PERM_API_READ = 'api.read';
|
||||
|
||||
public const PERM_API_WRITE = 'api.write';
|
||||
|
||||
public const PERM_API_ADMIN = 'api.admin';
|
||||
|
||||
protected $fillable = [
|
||||
'workspace_id',
|
||||
'name',
|
||||
'slug',
|
||||
'description',
|
||||
'permissions',
|
||||
'is_default',
|
||||
'is_system',
|
||||
'colour',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'permissions' => 'array',
|
||||
'is_default' => 'boolean',
|
||||
'is_system' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Boot
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
protected static function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function (self $team) {
|
||||
if (empty($team->slug)) {
|
||||
$team->slug = Str::slug($team->name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Relationships
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get members assigned to this team via the pivot.
|
||||
*/
|
||||
public function members(): HasMany
|
||||
{
|
||||
return $this->hasMany(WorkspaceMember::class, 'team_id');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Scopes
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scope to default teams only.
|
||||
*/
|
||||
public function scopeDefault($query)
|
||||
{
|
||||
return $query->where('is_default', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to system teams only.
|
||||
*/
|
||||
public function scopeSystem($query)
|
||||
{
|
||||
return $query->where('is_system', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to custom (non-system) teams only.
|
||||
*/
|
||||
public function scopeCustom($query)
|
||||
{
|
||||
return $query->where('is_system', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope ordered by sort_order.
|
||||
*/
|
||||
public function scopeOrdered($query)
|
||||
{
|
||||
return $query->orderBy('sort_order');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Permission Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if this team has a specific permission.
|
||||
*/
|
||||
public function hasPermission(string $permission): bool
|
||||
{
|
||||
$permissions = $this->permissions ?? [];
|
||||
|
||||
// Check for exact match
|
||||
if (in_array($permission, $permissions, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for wildcard permissions (e.g., 'bio.*' matches 'bio.read')
|
||||
foreach ($permissions as $perm) {
|
||||
if (str_ends_with($perm, '.*')) {
|
||||
$prefix = substr($perm, 0, -1); // Remove the '*'
|
||||
if (str_starts_with($permission, $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this team has any of the given permissions.
|
||||
*/
|
||||
public function hasAnyPermission(array $permissions): bool
|
||||
{
|
||||
foreach ($permissions as $permission) {
|
||||
if ($this->hasPermission($permission)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this team has all of the given permissions.
|
||||
*/
|
||||
public function hasAllPermissions(array $permissions): bool
|
||||
{
|
||||
foreach ($permissions as $permission) {
|
||||
if (! $this->hasPermission($permission)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant a permission to this team.
|
||||
*/
|
||||
public function grantPermission(string $permission): self
|
||||
{
|
||||
$permissions = $this->permissions ?? [];
|
||||
|
||||
if (! in_array($permission, $permissions, true)) {
|
||||
$permissions[] = $permission;
|
||||
$this->update(['permissions' => $permissions]);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a permission from this team.
|
||||
*/
|
||||
public function revokePermission(string $permission): self
|
||||
{
|
||||
$permissions = $this->permissions ?? [];
|
||||
$permissions = array_values(array_filter($permissions, fn ($p) => $p !== $permission));
|
||||
|
||||
$this->update(['permissions' => $permissions]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set all permissions for this team.
|
||||
*/
|
||||
public function setPermissions(array $permissions): self
|
||||
{
|
||||
$this->update(['permissions' => $permissions]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Static Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get all available permissions grouped by category.
|
||||
*/
|
||||
public static function getAvailablePermissions(): array
|
||||
{
|
||||
return [
|
||||
'workspace' => [
|
||||
'label' => 'Workspace',
|
||||
'permissions' => [
|
||||
self::PERM_WORKSPACE_SETTINGS => 'Manage settings',
|
||||
self::PERM_WORKSPACE_MEMBERS => 'Manage members',
|
||||
self::PERM_WORKSPACE_TEAMS => 'Manage teams',
|
||||
self::PERM_WORKSPACE_BILLING => 'Manage billing',
|
||||
self::PERM_WORKSPACE_DELETE => 'Delete workspace',
|
||||
],
|
||||
],
|
||||
'bio' => [
|
||||
'label' => 'BioHost',
|
||||
'permissions' => [
|
||||
self::PERM_BIO_READ => 'View pages',
|
||||
self::PERM_BIO_WRITE => 'Create and edit pages',
|
||||
self::PERM_BIO_ADMIN => 'Full access',
|
||||
],
|
||||
],
|
||||
'social' => [
|
||||
'label' => 'SocialHost',
|
||||
'permissions' => [
|
||||
self::PERM_SOCIAL_READ => 'View posts and accounts',
|
||||
self::PERM_SOCIAL_WRITE => 'Create and edit posts',
|
||||
self::PERM_SOCIAL_ADMIN => 'Full access',
|
||||
],
|
||||
],
|
||||
'analytics' => [
|
||||
'label' => 'AnalyticsHost',
|
||||
'permissions' => [
|
||||
self::PERM_ANALYTICS_READ => 'View analytics',
|
||||
self::PERM_ANALYTICS_WRITE => 'Configure tracking',
|
||||
self::PERM_ANALYTICS_ADMIN => 'Full access',
|
||||
],
|
||||
],
|
||||
'trust' => [
|
||||
'label' => 'TrustHost',
|
||||
'permissions' => [
|
||||
self::PERM_TRUST_READ => 'View campaigns',
|
||||
self::PERM_TRUST_WRITE => 'Create and edit campaigns',
|
||||
self::PERM_TRUST_ADMIN => 'Full access',
|
||||
],
|
||||
],
|
||||
'notify' => [
|
||||
'label' => 'NotifyHost',
|
||||
'permissions' => [
|
||||
self::PERM_NOTIFY_READ => 'View notifications',
|
||||
self::PERM_NOTIFY_WRITE => 'Send notifications',
|
||||
self::PERM_NOTIFY_ADMIN => 'Full access',
|
||||
],
|
||||
],
|
||||
'support' => [
|
||||
'label' => 'SupportHost',
|
||||
'permissions' => [
|
||||
self::PERM_SUPPORT_READ => 'View conversations',
|
||||
self::PERM_SUPPORT_WRITE => 'Reply to conversations',
|
||||
self::PERM_SUPPORT_ADMIN => 'Full access',
|
||||
],
|
||||
],
|
||||
'commerce' => [
|
||||
'label' => 'Commerce',
|
||||
'permissions' => [
|
||||
self::PERM_COMMERCE_READ => 'View orders and invoices',
|
||||
self::PERM_COMMERCE_WRITE => 'Manage orders',
|
||||
self::PERM_COMMERCE_ADMIN => 'Full access',
|
||||
],
|
||||
],
|
||||
'api' => [
|
||||
'label' => 'API',
|
||||
'permissions' => [
|
||||
self::PERM_API_READ => 'View API keys',
|
||||
self::PERM_API_WRITE => 'Create API keys',
|
||||
self::PERM_API_ADMIN => 'Full access',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get flat list of all permission keys.
|
||||
*/
|
||||
public static function getAllPermissionKeys(): array
|
||||
{
|
||||
$keys = [];
|
||||
foreach (self::getAvailablePermissions() as $group) {
|
||||
$keys = array_merge($keys, array_keys($group['permissions']));
|
||||
}
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default permissions for a given team type.
|
||||
*/
|
||||
public static function getDefaultPermissionsFor(string $teamSlug): array
|
||||
{
|
||||
return match ($teamSlug) {
|
||||
self::TEAM_OWNER => self::getAllPermissionKeys(), // Owner gets all permissions
|
||||
self::TEAM_ADMIN => array_filter(
|
||||
self::getAllPermissionKeys(),
|
||||
fn ($p) => ! in_array($p, [
|
||||
self::PERM_WORKSPACE_DELETE,
|
||||
self::PERM_WORKSPACE_BILLING,
|
||||
], true)
|
||||
),
|
||||
self::TEAM_MEMBER => [
|
||||
self::PERM_BIO_READ,
|
||||
self::PERM_BIO_WRITE,
|
||||
self::PERM_SOCIAL_READ,
|
||||
self::PERM_SOCIAL_WRITE,
|
||||
self::PERM_ANALYTICS_READ,
|
||||
self::PERM_TRUST_READ,
|
||||
self::PERM_TRUST_WRITE,
|
||||
self::PERM_NOTIFY_READ,
|
||||
self::PERM_NOTIFY_WRITE,
|
||||
self::PERM_SUPPORT_READ,
|
||||
self::PERM_SUPPORT_WRITE,
|
||||
self::PERM_COMMERCE_READ,
|
||||
self::PERM_API_READ,
|
||||
],
|
||||
self::TEAM_VIEWER => [
|
||||
self::PERM_BIO_READ,
|
||||
self::PERM_SOCIAL_READ,
|
||||
self::PERM_ANALYTICS_READ,
|
||||
self::PERM_TRUST_READ,
|
||||
self::PERM_NOTIFY_READ,
|
||||
self::PERM_SUPPORT_READ,
|
||||
self::PERM_COMMERCE_READ,
|
||||
self::PERM_API_READ,
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default team definitions for seeding.
|
||||
*/
|
||||
public static function getDefaultTeamDefinitions(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'name' => 'Owner',
|
||||
'slug' => self::TEAM_OWNER,
|
||||
'description' => 'Full ownership access to the workspace.',
|
||||
'permissions' => self::getDefaultPermissionsFor(self::TEAM_OWNER),
|
||||
'is_system' => true,
|
||||
'colour' => 'violet',
|
||||
'sort_order' => 1,
|
||||
],
|
||||
[
|
||||
'name' => 'Admin',
|
||||
'slug' => self::TEAM_ADMIN,
|
||||
'description' => 'Administrative access without billing or deletion rights.',
|
||||
'permissions' => self::getDefaultPermissionsFor(self::TEAM_ADMIN),
|
||||
'is_system' => true,
|
||||
'colour' => 'blue',
|
||||
'sort_order' => 2,
|
||||
],
|
||||
[
|
||||
'name' => 'Member',
|
||||
'slug' => self::TEAM_MEMBER,
|
||||
'description' => 'Standard member access to create and edit content.',
|
||||
'permissions' => self::getDefaultPermissionsFor(self::TEAM_MEMBER),
|
||||
'is_system' => true,
|
||||
'is_default' => true,
|
||||
'colour' => 'emerald',
|
||||
'sort_order' => 3,
|
||||
],
|
||||
[
|
||||
'name' => 'Viewer',
|
||||
'slug' => self::TEAM_VIEWER,
|
||||
'description' => 'Read-only access to view content.',
|
||||
'permissions' => self::getDefaultPermissionsFor(self::TEAM_VIEWER),
|
||||
'is_system' => true,
|
||||
'colour' => 'zinc',
|
||||
'sort_order' => 4,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available colour options for teams.
|
||||
*/
|
||||
public static function getColourOptions(): array
|
||||
{
|
||||
return [
|
||||
'zinc' => 'Grey',
|
||||
'red' => 'Red',
|
||||
'orange' => 'Orange',
|
||||
'amber' => 'Amber',
|
||||
'yellow' => 'Yellow',
|
||||
'lime' => 'Lime',
|
||||
'green' => 'Green',
|
||||
'emerald' => 'Emerald',
|
||||
'teal' => 'Teal',
|
||||
'cyan' => 'Cyan',
|
||||
'sky' => 'Sky',
|
||||
'blue' => 'Blue',
|
||||
'indigo' => 'Indigo',
|
||||
'violet' => 'Violet',
|
||||
'purple' => 'Purple',
|
||||
'fuchsia' => 'Fuchsia',
|
||||
'pink' => 'Pink',
|
||||
'rose' => 'Rose',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,629 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Tenant\Services;
|
||||
|
||||
use Core\Mod\Tenant\Models\User;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
use Core\Mod\Tenant\Models\WorkspaceMember;
|
||||
use Core\Mod\Tenant\Models\WorkspaceTeam;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Workspace Team Service - manages workspace teams and member permissions.
|
||||
*/
|
||||
class WorkspaceTeamService
|
||||
{
|
||||
protected ?Workspace $workspace = null;
|
||||
|
||||
public function __construct(?Workspace $workspace = null)
|
||||
{
|
||||
$this->workspace = $workspace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the workspace context.
|
||||
*/
|
||||
public function forWorkspace(Workspace $workspace): self
|
||||
{
|
||||
$this->workspace = $workspace;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current workspace, resolving from context if needed.
|
||||
*/
|
||||
protected function getWorkspace(): ?Workspace
|
||||
{
|
||||
if ($this->workspace) {
|
||||
return $this->workspace;
|
||||
}
|
||||
|
||||
// Try authenticated user's default workspace first
|
||||
$this->workspace = auth()->user()?->defaultHostWorkspace();
|
||||
|
||||
// Fall back to session workspace if set
|
||||
if (! $this->workspace) {
|
||||
$sessionWorkspaceId = session('workspace_id');
|
||||
if ($sessionWorkspaceId) {
|
||||
$this->workspace = Workspace::find($sessionWorkspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->workspace;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Team Management
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get all teams for the workspace.
|
||||
*/
|
||||
public function getTeams(): Collection
|
||||
{
|
||||
$workspace = $this->getWorkspace();
|
||||
if (! $workspace) {
|
||||
return new Collection;
|
||||
}
|
||||
|
||||
return WorkspaceTeam::where('workspace_id', $workspace->id)
|
||||
->ordered()
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific team by ID.
|
||||
*/
|
||||
public function getTeam(int $teamId): ?WorkspaceTeam
|
||||
{
|
||||
$workspace = $this->getWorkspace();
|
||||
if (! $workspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return WorkspaceTeam::where('workspace_id', $workspace->id)
|
||||
->where('id', $teamId)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific team by slug.
|
||||
*/
|
||||
public function getTeamBySlug(string $slug): ?WorkspaceTeam
|
||||
{
|
||||
$workspace = $this->getWorkspace();
|
||||
if (! $workspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return WorkspaceTeam::where('workspace_id', $workspace->id)
|
||||
->where('slug', $slug)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default team for new members.
|
||||
*/
|
||||
public function getDefaultTeam(): ?WorkspaceTeam
|
||||
{
|
||||
$workspace = $this->getWorkspace();
|
||||
if (! $workspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return WorkspaceTeam::where('workspace_id', $workspace->id)
|
||||
->where('is_default', true)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new team.
|
||||
*/
|
||||
public function createTeam(array $data): WorkspaceTeam
|
||||
{
|
||||
$workspace = $this->getWorkspace();
|
||||
if (! $workspace) {
|
||||
throw new \RuntimeException('No workspace context available.');
|
||||
}
|
||||
|
||||
$team = WorkspaceTeam::create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'name' => $data['name'],
|
||||
'slug' => $data['slug'] ?? null,
|
||||
'description' => $data['description'] ?? null,
|
||||
'permissions' => $data['permissions'] ?? [],
|
||||
'is_default' => $data['is_default'] ?? false,
|
||||
'is_system' => $data['is_system'] ?? false,
|
||||
'colour' => $data['colour'] ?? 'zinc',
|
||||
'sort_order' => $data['sort_order'] ?? 0,
|
||||
]);
|
||||
|
||||
// If this is the new default, unset other defaults
|
||||
if ($team->is_default) {
|
||||
WorkspaceTeam::where('workspace_id', $workspace->id)
|
||||
->where('id', '!=', $team->id)
|
||||
->where('is_default', true)
|
||||
->update(['is_default' => false]);
|
||||
}
|
||||
|
||||
Log::info('Workspace team created', [
|
||||
'team_id' => $team->id,
|
||||
'team_name' => $team->name,
|
||||
'workspace_id' => $workspace->id,
|
||||
]);
|
||||
|
||||
return $team;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing team.
|
||||
*/
|
||||
public function updateTeam(WorkspaceTeam $team, array $data): WorkspaceTeam
|
||||
{
|
||||
$workspace = $this->getWorkspace();
|
||||
|
||||
// Don't allow updating system teams' slug
|
||||
if ($team->is_system && isset($data['slug'])) {
|
||||
unset($data['slug']);
|
||||
}
|
||||
|
||||
$team->update($data);
|
||||
|
||||
// If this is the new default, unset other defaults
|
||||
if (($data['is_default'] ?? false) && $workspace) {
|
||||
WorkspaceTeam::where('workspace_id', $workspace->id)
|
||||
->where('id', '!=', $team->id)
|
||||
->where('is_default', true)
|
||||
->update(['is_default' => false]);
|
||||
}
|
||||
|
||||
Log::info('Workspace team updated', [
|
||||
'team_id' => $team->id,
|
||||
'team_name' => $team->name,
|
||||
'workspace_id' => $team->workspace_id,
|
||||
]);
|
||||
|
||||
return $team;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a team (only non-system teams).
|
||||
*/
|
||||
public function deleteTeam(WorkspaceTeam $team): bool
|
||||
{
|
||||
if ($team->is_system) {
|
||||
throw new \RuntimeException('Cannot delete system teams.');
|
||||
}
|
||||
|
||||
// Check if team has any members assigned
|
||||
$memberCount = WorkspaceMember::where('team_id', $team->id)->count();
|
||||
if ($memberCount > 0) {
|
||||
throw new \RuntimeException(
|
||||
"Cannot delete team with {$memberCount} assigned members. Remove members first."
|
||||
);
|
||||
}
|
||||
|
||||
$teamId = $team->id;
|
||||
$teamName = $team->name;
|
||||
$workspaceId = $team->workspace_id;
|
||||
|
||||
$team->delete();
|
||||
|
||||
Log::info('Workspace team deleted', [
|
||||
'team_id' => $teamId,
|
||||
'team_name' => $teamName,
|
||||
'workspace_id' => $workspaceId,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Member Management
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get a member record for a user in the workspace.
|
||||
*/
|
||||
public function getMember(User|int $user): ?WorkspaceMember
|
||||
{
|
||||
$workspace = $this->getWorkspace();
|
||||
if (! $workspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$userId = $user instanceof User ? $user->id : $user;
|
||||
|
||||
return WorkspaceMember::where('workspace_id', $workspace->id)
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all members in the workspace.
|
||||
*/
|
||||
public function getMembers(): Collection
|
||||
{
|
||||
$workspace = $this->getWorkspace();
|
||||
if (! $workspace) {
|
||||
return new Collection;
|
||||
}
|
||||
|
||||
return WorkspaceMember::where('workspace_id', $workspace->id)
|
||||
->with(['user', 'team', 'inviter'])
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all members in a specific team.
|
||||
*/
|
||||
public function getTeamMembers(WorkspaceTeam|int $team): Collection
|
||||
{
|
||||
$workspace = $this->getWorkspace();
|
||||
if (! $workspace) {
|
||||
return new Collection;
|
||||
}
|
||||
|
||||
$teamId = $team instanceof WorkspaceTeam ? $team->id : $team;
|
||||
|
||||
return WorkspaceMember::where('workspace_id', $workspace->id)
|
||||
->where('team_id', $teamId)
|
||||
->with(['user', 'team', 'inviter'])
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a member to a team.
|
||||
*/
|
||||
public function addMemberToTeam(User|int $user, WorkspaceTeam|int $team): WorkspaceMember
|
||||
{
|
||||
$workspace = $this->getWorkspace();
|
||||
if (! $workspace) {
|
||||
throw new \RuntimeException('No workspace context available.');
|
||||
}
|
||||
|
||||
$userId = $user instanceof User ? $user->id : $user;
|
||||
$teamId = $team instanceof WorkspaceTeam ? $team->id : $team;
|
||||
|
||||
// Verify team belongs to workspace
|
||||
$teamModel = WorkspaceTeam::where('workspace_id', $workspace->id)
|
||||
->where('id', $teamId)
|
||||
->first();
|
||||
|
||||
if (! $teamModel) {
|
||||
throw new \RuntimeException('Team does not belong to the current workspace.');
|
||||
}
|
||||
|
||||
$member = WorkspaceMember::where('workspace_id', $workspace->id)
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
|
||||
if (! $member) {
|
||||
throw new \RuntimeException('User is not a member of this workspace.');
|
||||
}
|
||||
|
||||
$member->update(['team_id' => $teamId]);
|
||||
|
||||
Log::info('Member added to team', [
|
||||
'user_id' => $userId,
|
||||
'team_id' => $teamId,
|
||||
'team_name' => $teamModel->name,
|
||||
'workspace_id' => $workspace->id,
|
||||
]);
|
||||
|
||||
return $member->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member from their team.
|
||||
*/
|
||||
public function removeMemberFromTeam(User|int $user): WorkspaceMember
|
||||
{
|
||||
$workspace = $this->getWorkspace();
|
||||
if (! $workspace) {
|
||||
throw new \RuntimeException('No workspace context available.');
|
||||
}
|
||||
|
||||
$userId = $user instanceof User ? $user->id : $user;
|
||||
|
||||
$member = WorkspaceMember::where('workspace_id', $workspace->id)
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
|
||||
if (! $member) {
|
||||
throw new \RuntimeException('User is not a member of this workspace.');
|
||||
}
|
||||
|
||||
$oldTeamId = $member->team_id;
|
||||
$member->update(['team_id' => null]);
|
||||
|
||||
Log::info('Member removed from team', [
|
||||
'user_id' => $userId,
|
||||
'old_team_id' => $oldTeamId,
|
||||
'workspace_id' => $workspace->id,
|
||||
]);
|
||||
|
||||
return $member->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set custom permissions for a member.
|
||||
*/
|
||||
public function setMemberCustomPermissions(User|int $user, array $customPermissions): WorkspaceMember
|
||||
{
|
||||
$workspace = $this->getWorkspace();
|
||||
if (! $workspace) {
|
||||
throw new \RuntimeException('No workspace context available.');
|
||||
}
|
||||
|
||||
$userId = $user instanceof User ? $user->id : $user;
|
||||
|
||||
$member = WorkspaceMember::where('workspace_id', $workspace->id)
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
|
||||
if (! $member) {
|
||||
throw new \RuntimeException('User is not a member of this workspace.');
|
||||
}
|
||||
|
||||
$member->update(['custom_permissions' => $customPermissions]);
|
||||
|
||||
Log::info('Member custom permissions updated', [
|
||||
'user_id' => $userId,
|
||||
'workspace_id' => $workspace->id,
|
||||
'custom_permissions' => $customPermissions,
|
||||
]);
|
||||
|
||||
return $member->fresh();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Permission Checks
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get all effective permissions for a user in the workspace.
|
||||
*/
|
||||
public function getMemberPermissions(User|int $user): array
|
||||
{
|
||||
$member = $this->getMember($user);
|
||||
|
||||
if (! $member) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $member->getEffectivePermissions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has a specific permission in the workspace.
|
||||
*/
|
||||
public function hasPermission(User|int $user, string $permission): bool
|
||||
{
|
||||
$member = $this->getMember($user);
|
||||
|
||||
if (! $member) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $member->hasPermission($permission);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has any of the given permissions.
|
||||
*/
|
||||
public function hasAnyPermission(User|int $user, array $permissions): bool
|
||||
{
|
||||
$member = $this->getMember($user);
|
||||
|
||||
if (! $member) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $member->hasAnyPermission($permissions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has all of the given permissions.
|
||||
*/
|
||||
public function hasAllPermissions(User|int $user, array $permissions): bool
|
||||
{
|
||||
$member = $this->getMember($user);
|
||||
|
||||
if (! $member) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $member->hasAllPermissions($permissions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is the workspace owner.
|
||||
*/
|
||||
public function isOwner(User|int $user): bool
|
||||
{
|
||||
$member = $this->getMember($user);
|
||||
|
||||
return $member?->isOwner() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is a workspace admin.
|
||||
*/
|
||||
public function isAdmin(User|int $user): bool
|
||||
{
|
||||
$member = $this->getMember($user);
|
||||
|
||||
return $member?->isAdmin() ?? false;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Member Queries
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get members with a specific permission.
|
||||
*/
|
||||
public function getMembersWithPermission(string $permission): Collection
|
||||
{
|
||||
$members = $this->getMembers();
|
||||
|
||||
return $members->filter(fn ($member) => $member->hasPermission($permission));
|
||||
}
|
||||
|
||||
/**
|
||||
* Count members in the workspace.
|
||||
*/
|
||||
public function countMembers(): int
|
||||
{
|
||||
$workspace = $this->getWorkspace();
|
||||
if (! $workspace) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return WorkspaceMember::where('workspace_id', $workspace->id)->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Count members in a specific team.
|
||||
*/
|
||||
public function countTeamMembers(WorkspaceTeam|int $team): int
|
||||
{
|
||||
$workspace = $this->getWorkspace();
|
||||
if (! $workspace) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$teamId = $team instanceof WorkspaceTeam ? $team->id : $team;
|
||||
|
||||
return WorkspaceMember::where('workspace_id', $workspace->id)
|
||||
->where('team_id', $teamId)
|
||||
->count();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Seeding
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Seed default teams for a workspace.
|
||||
*/
|
||||
public function seedDefaultTeams(?Workspace $workspace = null): Collection
|
||||
{
|
||||
$workspace = $workspace ?? $this->getWorkspace();
|
||||
if (! $workspace) {
|
||||
throw new \RuntimeException('No workspace context available for seeding.');
|
||||
}
|
||||
|
||||
$teams = new Collection;
|
||||
|
||||
foreach (WorkspaceTeam::getDefaultTeamDefinitions() as $definition) {
|
||||
// Check if team already exists
|
||||
$existing = WorkspaceTeam::where('workspace_id', $workspace->id)
|
||||
->where('slug', $definition['slug'])
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$teams->push($existing);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$team = WorkspaceTeam::create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'name' => $definition['name'],
|
||||
'slug' => $definition['slug'],
|
||||
'description' => $definition['description'],
|
||||
'permissions' => $definition['permissions'],
|
||||
'is_default' => $definition['is_default'] ?? false,
|
||||
'is_system' => $definition['is_system'] ?? false,
|
||||
'colour' => $definition['colour'] ?? 'zinc',
|
||||
'sort_order' => $definition['sort_order'] ?? 0,
|
||||
]);
|
||||
|
||||
$teams->push($team);
|
||||
}
|
||||
|
||||
Log::info('Default workspace teams seeded', [
|
||||
'workspace_id' => $workspace->id,
|
||||
'teams_count' => $teams->count(),
|
||||
]);
|
||||
|
||||
return $teams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure default teams exist for the workspace, creating them if needed.
|
||||
*/
|
||||
public function ensureDefaultTeams(): Collection
|
||||
{
|
||||
$workspace = $this->getWorkspace();
|
||||
if (! $workspace) {
|
||||
return new Collection;
|
||||
}
|
||||
|
||||
// Check if any teams exist
|
||||
$existingCount = WorkspaceTeam::where('workspace_id', $workspace->id)->count();
|
||||
|
||||
if ($existingCount === 0) {
|
||||
return $this->seedDefaultTeams($workspace);
|
||||
}
|
||||
|
||||
return $this->getTeams();
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate existing members to appropriate teams based on their role.
|
||||
*/
|
||||
public function migrateExistingMembers(): int
|
||||
{
|
||||
$workspace = $this->getWorkspace();
|
||||
if (! $workspace) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Ensure teams exist
|
||||
$this->ensureDefaultTeams();
|
||||
|
||||
$ownerTeam = $this->getTeamBySlug(WorkspaceTeam::TEAM_OWNER);
|
||||
$adminTeam = $this->getTeamBySlug(WorkspaceTeam::TEAM_ADMIN);
|
||||
$memberTeam = $this->getTeamBySlug(WorkspaceTeam::TEAM_MEMBER);
|
||||
|
||||
$migrated = 0;
|
||||
|
||||
DB::transaction(function () use ($workspace, $ownerTeam, $adminTeam, $memberTeam, &$migrated) {
|
||||
// Get members without team assignments
|
||||
$members = WorkspaceMember::where('workspace_id', $workspace->id)
|
||||
->whereNull('team_id')
|
||||
->get();
|
||||
|
||||
foreach ($members as $member) {
|
||||
$teamId = match ($member->role) {
|
||||
WorkspaceMember::ROLE_OWNER => $ownerTeam?->id,
|
||||
WorkspaceMember::ROLE_ADMIN => $adminTeam?->id,
|
||||
default => $memberTeam?->id,
|
||||
};
|
||||
|
||||
if ($teamId) {
|
||||
$member->update([
|
||||
'team_id' => $teamId,
|
||||
'joined_at' => $member->joined_at ?? $member->created_at,
|
||||
]);
|
||||
$migrated++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Log::info('Workspace members migrated to teams', [
|
||||
'workspace_id' => $workspace->id,
|
||||
'migrated_count' => $migrated,
|
||||
]);
|
||||
|
||||
return $migrated;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue