feat(api): add webhook template manager and secret rotation
- Add WebhookPayloadTemplate model with builtin template support - Add WebhookTemplateService for template rendering (Mustache, JSON) - Add WebhookSecretRotationService with grace period handling - Add WebhookTemplateController and WebhookSecretController API endpoints - Add WebhookTemplateManager Livewire component for admin UI - Add CleanupExpiredSecrets console command - Add BuiltinTemplateType and WebhookTemplateFormat enums - Add migrations for api tables and secret rotation fields Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
931974645b
commit
9cc9e4a178
15 changed files with 3270 additions and 0 deletions
|
|
@ -4,11 +4,15 @@ declare(strict_types=1);
|
|||
|
||||
namespace Core\Mod\Api;
|
||||
|
||||
use Core\Events\AdminPanelBooting;
|
||||
use Core\Events\ApiRoutesRegistering;
|
||||
use Core\Events\ConsoleBooting;
|
||||
use Core\Mod\Api\Documentation\DocumentationServiceProvider;
|
||||
use Core\Mod\Api\RateLimit\RateLimitService;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Contracts\Cache\Repository as CacheRepository;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
|
|
@ -32,6 +36,7 @@ class Boot extends ServiceProvider
|
|||
* @var array<class-string, string>
|
||||
*/
|
||||
public static array $listens = [
|
||||
AdminPanelBooting::class => 'onAdminPanel',
|
||||
ApiRoutesRegistering::class => 'onApiRoutes',
|
||||
ConsoleBooting::class => 'onConsole',
|
||||
];
|
||||
|
|
@ -51,6 +56,10 @@ class Boot extends ServiceProvider
|
|||
return new RateLimitService($app->make(CacheRepository::class));
|
||||
});
|
||||
|
||||
// Register webhook services
|
||||
$this->app->singleton(Services\WebhookTemplateService::class);
|
||||
$this->app->singleton(Services\WebhookSecretRotationService::class);
|
||||
|
||||
// Register API Documentation provider
|
||||
$this->app->register(DocumentationServiceProvider::class);
|
||||
}
|
||||
|
|
@ -61,12 +70,48 @@ class Boot extends ServiceProvider
|
|||
public function boot(): void
|
||||
{
|
||||
$this->loadMigrationsFrom(__DIR__.'/Migrations');
|
||||
$this->configureRateLimiting();
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure rate limiters for API endpoints.
|
||||
*/
|
||||
protected function configureRateLimiting(): void
|
||||
{
|
||||
// Rate limit for webhook template operations: 30 per minute per user
|
||||
RateLimiter::for('api-webhook-templates', function (Request $request) {
|
||||
$user = $request->user();
|
||||
|
||||
return $user
|
||||
? Limit::perMinute(30)->by('user:'.$user->id)
|
||||
: Limit::perMinute(10)->by($request->ip());
|
||||
});
|
||||
|
||||
// Rate limit for template preview/validation: 60 per minute per user
|
||||
RateLimiter::for('api-template-preview', function (Request $request) {
|
||||
$user = $request->user();
|
||||
|
||||
return $user
|
||||
? Limit::perMinute(60)->by('user:'.$user->id)
|
||||
: Limit::perMinute(20)->by($request->ip());
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Event-driven handlers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function onAdminPanel(AdminPanelBooting $event): void
|
||||
{
|
||||
$event->views($this->moduleName, __DIR__.'/View/Blade');
|
||||
|
||||
if (file_exists(__DIR__.'/Routes/admin.php')) {
|
||||
$event->routes(fn () => require __DIR__.'/Routes/admin.php');
|
||||
}
|
||||
|
||||
$event->livewire('api.webhook-template-manager', View\Modal\Admin\WebhookTemplateManager::class);
|
||||
}
|
||||
|
||||
public function onApiRoutes(ApiRoutesRegistering $event): void
|
||||
{
|
||||
// Middleware aliases registered via event
|
||||
|
|
|
|||
141
src/Mod/Api/Console/Commands/CleanupExpiredSecrets.php
Normal file
141
src/Mod/Api/Console/Commands/CleanupExpiredSecrets.php
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Api\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Core\Mod\Api\Services\WebhookSecretRotationService;
|
||||
use Core\Content\Models\ContentWebhookEndpoint;
|
||||
use Core\Social\Models\Webhook;
|
||||
|
||||
/**
|
||||
* Clean up expired webhook secret grace periods.
|
||||
*
|
||||
* Removes previous_secret values from webhooks where the grace period has expired.
|
||||
* This command should be run periodically (e.g., daily via scheduler).
|
||||
*/
|
||||
class CleanupExpiredSecrets extends Command
|
||||
{
|
||||
protected $signature = 'webhook:cleanup-secrets
|
||||
{--dry-run : Show what would be cleaned up without making changes}
|
||||
{--model= : Only process a specific model (social, content)}';
|
||||
|
||||
protected $description = 'Clean up expired webhook secret grace periods';
|
||||
|
||||
/**
|
||||
* Webhook model classes to process.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected array $webhookModels = [
|
||||
'social' => Webhook::class,
|
||||
'content' => ContentWebhookEndpoint::class,
|
||||
];
|
||||
|
||||
public function handle(WebhookSecretRotationService $service): int
|
||||
{
|
||||
$dryRun = $this->option('dry-run');
|
||||
$modelFilter = $this->option('model');
|
||||
|
||||
$this->info('Starting webhook secret cleanup...');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('DRY RUN MODE - No data will be modified');
|
||||
}
|
||||
|
||||
$startTime = microtime(true);
|
||||
$totalCleaned = 0;
|
||||
|
||||
$modelsToProcess = $this->getModelsToProcess($modelFilter);
|
||||
|
||||
if (empty($modelsToProcess)) {
|
||||
$this->error('No valid models to process.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
foreach ($modelsToProcess as $name => $modelClass) {
|
||||
if (! class_exists($modelClass)) {
|
||||
$this->warn("Model class {$modelClass} not found, skipping...");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->info("Processing {$name} webhooks...");
|
||||
|
||||
if ($dryRun) {
|
||||
$count = $this->countExpiredGracePeriods($modelClass, $service);
|
||||
$this->line(" Would clean up: {$count} webhook(s)");
|
||||
$totalCleaned += $count;
|
||||
} else {
|
||||
$count = $service->cleanupExpiredGracePeriods($modelClass);
|
||||
$this->line(" Cleaned up: {$count} webhook(s)");
|
||||
$totalCleaned += $count;
|
||||
}
|
||||
}
|
||||
|
||||
$elapsed = round(microtime(true) - $startTime, 2);
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Cleanup Summary:');
|
||||
$this->line(" Total cleaned: {$totalCleaned} webhook(s)");
|
||||
$this->line(" Time elapsed: {$elapsed}s");
|
||||
|
||||
if (! $dryRun && $totalCleaned > 0) {
|
||||
Log::info('Webhook secret cleanup completed', [
|
||||
'total_cleaned' => $totalCleaned,
|
||||
'elapsed_seconds' => $elapsed,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->info('Webhook secret cleanup complete.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the webhook models to process based on the filter.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function getModelsToProcess(?string $filter): array
|
||||
{
|
||||
if ($filter === null) {
|
||||
return $this->webhookModels;
|
||||
}
|
||||
|
||||
$filter = strtolower($filter);
|
||||
|
||||
if (! isset($this->webhookModels[$filter])) {
|
||||
$this->error("Invalid model filter: {$filter}");
|
||||
$this->line('Available models: '.implode(', ', array_keys($this->webhookModels)));
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return [$filter => $this->webhookModels[$filter]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Count webhooks with expired grace periods (for dry run).
|
||||
*/
|
||||
protected function countExpiredGracePeriods(string $modelClass, WebhookSecretRotationService $service): int
|
||||
{
|
||||
$count = 0;
|
||||
|
||||
$modelClass::query()
|
||||
->whereNotNull('previous_secret')
|
||||
->whereNotNull('secret_rotated_at')
|
||||
->chunkById(100, function ($webhooks) use ($service, &$count) {
|
||||
foreach ($webhooks as $webhook) {
|
||||
if (! $service->isInGracePeriod($webhook)) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
35
src/Mod/Api/Contracts/WebhookEvent.php
Normal file
35
src/Mod/Api/Contracts/WebhookEvent.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Api\Contracts;
|
||||
|
||||
/**
|
||||
* Contract for webhook events that can be rendered with templates.
|
||||
*
|
||||
* Any event that wants to use webhook templating must implement this interface.
|
||||
*/
|
||||
interface WebhookEvent
|
||||
{
|
||||
/**
|
||||
* Get the event identifier (e.g., 'post.published', 'user.created').
|
||||
*/
|
||||
public static function name(): string;
|
||||
|
||||
/**
|
||||
* Get the human-readable event name for display.
|
||||
*/
|
||||
public static function nameLocalised(): string;
|
||||
|
||||
/**
|
||||
* Get the event payload data.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function payload(): array;
|
||||
|
||||
/**
|
||||
* Get a human-readable message describing the event.
|
||||
*/
|
||||
public function message(): string;
|
||||
}
|
||||
268
src/Mod/Api/Controllers/Api/WebhookSecretController.php
Normal file
268
src/Mod/Api/Controllers/Api/WebhookSecretController.php
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Api\Controllers\Api;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Core\Mod\Api\Services\WebhookSecretRotationService;
|
||||
use Core\Content\Models\ContentWebhookEndpoint;
|
||||
use Core\Social\Models\Webhook;
|
||||
|
||||
/**
|
||||
* API controller for webhook secret rotation operations.
|
||||
*/
|
||||
class WebhookSecretController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected WebhookSecretRotationService $rotationService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Rotate a social webhook secret.
|
||||
*/
|
||||
public function rotateSocialSecret(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()?->defaultHostWorkspace();
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['error' => 'Workspace not found'], 404);
|
||||
}
|
||||
|
||||
$webhook = Webhook::where('workspace_id', $workspace->id)
|
||||
->where('uuid', $uuid)
|
||||
->first();
|
||||
|
||||
if (! $webhook) {
|
||||
return response()->json(['error' => 'Webhook not found'], 404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'grace_period_seconds' => 'nullable|integer|min:300|max:604800', // 5 min to 7 days
|
||||
]);
|
||||
|
||||
$newSecret = $this->rotationService->rotateSecret(
|
||||
$webhook,
|
||||
$validated['grace_period_seconds'] ?? null
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Secret rotated successfully',
|
||||
'data' => [
|
||||
'secret' => $newSecret,
|
||||
'status' => $this->rotationService->getSecretStatus($webhook->fresh()),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate a content webhook endpoint secret.
|
||||
*/
|
||||
public function rotateContentSecret(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()?->defaultHostWorkspace();
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['error' => 'Workspace not found'], 404);
|
||||
}
|
||||
|
||||
$endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id)
|
||||
->where('uuid', $uuid)
|
||||
->first();
|
||||
|
||||
if (! $endpoint) {
|
||||
return response()->json(['error' => 'Webhook endpoint not found'], 404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'grace_period_seconds' => 'nullable|integer|min:300|max:604800',
|
||||
]);
|
||||
|
||||
$newSecret = $this->rotationService->rotateSecret(
|
||||
$endpoint,
|
||||
$validated['grace_period_seconds'] ?? null
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Secret rotated successfully',
|
||||
'data' => [
|
||||
'secret' => $newSecret,
|
||||
'status' => $this->rotationService->getSecretStatus($endpoint->fresh()),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get secret rotation status for a social webhook.
|
||||
*/
|
||||
public function socialSecretStatus(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()?->defaultHostWorkspace();
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['error' => 'Workspace not found'], 404);
|
||||
}
|
||||
|
||||
$webhook = Webhook::where('workspace_id', $workspace->id)
|
||||
->where('uuid', $uuid)
|
||||
->first();
|
||||
|
||||
if (! $webhook) {
|
||||
return response()->json(['error' => 'Webhook not found'], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->rotationService->getSecretStatus($webhook),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get secret rotation status for a content webhook endpoint.
|
||||
*/
|
||||
public function contentSecretStatus(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()?->defaultHostWorkspace();
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['error' => 'Workspace not found'], 404);
|
||||
}
|
||||
|
||||
$endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id)
|
||||
->where('uuid', $uuid)
|
||||
->first();
|
||||
|
||||
if (! $endpoint) {
|
||||
return response()->json(['error' => 'Webhook endpoint not found'], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->rotationService->getSecretStatus($endpoint),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the previous secret for a social webhook.
|
||||
*/
|
||||
public function invalidateSocialPreviousSecret(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()?->defaultHostWorkspace();
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['error' => 'Workspace not found'], 404);
|
||||
}
|
||||
|
||||
$webhook = Webhook::where('workspace_id', $workspace->id)
|
||||
->where('uuid', $uuid)
|
||||
->first();
|
||||
|
||||
if (! $webhook) {
|
||||
return response()->json(['error' => 'Webhook not found'], 404);
|
||||
}
|
||||
|
||||
$this->rotationService->invalidatePreviousSecret($webhook);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Previous secret invalidated',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the previous secret for a content webhook endpoint.
|
||||
*/
|
||||
public function invalidateContentPreviousSecret(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()?->defaultHostWorkspace();
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['error' => 'Workspace not found'], 404);
|
||||
}
|
||||
|
||||
$endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id)
|
||||
->where('uuid', $uuid)
|
||||
->first();
|
||||
|
||||
if (! $endpoint) {
|
||||
return response()->json(['error' => 'Webhook endpoint not found'], 404);
|
||||
}
|
||||
|
||||
$this->rotationService->invalidatePreviousSecret($endpoint);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Previous secret invalidated',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the grace period for a social webhook.
|
||||
*/
|
||||
public function updateSocialGracePeriod(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()?->defaultHostWorkspace();
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['error' => 'Workspace not found'], 404);
|
||||
}
|
||||
|
||||
$webhook = Webhook::where('workspace_id', $workspace->id)
|
||||
->where('uuid', $uuid)
|
||||
->first();
|
||||
|
||||
if (! $webhook) {
|
||||
return response()->json(['error' => 'Webhook not found'], 404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'grace_period_seconds' => 'required|integer|min:300|max:604800',
|
||||
]);
|
||||
|
||||
$this->rotationService->updateGracePeriod($webhook, $validated['grace_period_seconds']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Grace period updated',
|
||||
'data' => [
|
||||
'grace_period_seconds' => $webhook->fresh()->grace_period_seconds,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the grace period for a content webhook endpoint.
|
||||
*/
|
||||
public function updateContentGracePeriod(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()?->defaultHostWorkspace();
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['error' => 'Workspace not found'], 404);
|
||||
}
|
||||
|
||||
$endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id)
|
||||
->where('uuid', $uuid)
|
||||
->first();
|
||||
|
||||
if (! $endpoint) {
|
||||
return response()->json(['error' => 'Webhook endpoint not found'], 404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'grace_period_seconds' => 'required|integer|min:300|max:604800',
|
||||
]);
|
||||
|
||||
$this->rotationService->updateGracePeriod($endpoint, $validated['grace_period_seconds']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Grace period updated',
|
||||
'data' => [
|
||||
'grace_period_seconds' => $endpoint->fresh()->grace_period_seconds,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
369
src/Mod/Api/Controllers/Api/WebhookTemplateController.php
Normal file
369
src/Mod/Api/Controllers/Api/WebhookTemplateController.php
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Api\Controllers\Api;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Str;
|
||||
use Core\Mod\Api\Enums\WebhookTemplateFormat;
|
||||
use Core\Mod\Api\Models\WebhookPayloadTemplate;
|
||||
use Core\Mod\Api\Services\WebhookTemplateService;
|
||||
|
||||
/**
|
||||
* API controller for managing webhook payload templates.
|
||||
*/
|
||||
class WebhookTemplateController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected WebhookTemplateService $templateService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* List all templates for the workspace.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()?->defaultHostWorkspace();
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['error' => 'Workspace not found'], 404);
|
||||
}
|
||||
|
||||
$query = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
|
||||
->active()
|
||||
->ordered();
|
||||
|
||||
// Optional filtering
|
||||
if ($request->has('builtin')) {
|
||||
$request->boolean('builtin')
|
||||
? $query->builtin()
|
||||
: $query->custom();
|
||||
}
|
||||
|
||||
$templates = $query->get()->map(fn ($template) => $this->formatTemplate($template));
|
||||
|
||||
return response()->json([
|
||||
'data' => $templates,
|
||||
'meta' => [
|
||||
'total' => $templates->count(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single template by UUID.
|
||||
*/
|
||||
public function show(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()?->defaultHostWorkspace();
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['error' => 'Workspace not found'], 404);
|
||||
}
|
||||
|
||||
$template = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
|
||||
->where('uuid', $uuid)
|
||||
->first();
|
||||
|
||||
if (! $template) {
|
||||
return response()->json(['error' => 'Template not found'], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->formatTemplate($template, true),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new template.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()?->defaultHostWorkspace();
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['error' => 'Workspace not found'], 404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'format' => 'required|in:simple,mustache,json',
|
||||
'template' => 'required|string|max:65535',
|
||||
'is_default' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
// Validate template syntax
|
||||
$format = WebhookTemplateFormat::from($validated['format']);
|
||||
$validation = $this->templateService->validateTemplate($validated['template'], $format);
|
||||
|
||||
if (! $validation['valid']) {
|
||||
return response()->json([
|
||||
'error' => 'Invalid template',
|
||||
'errors' => $validation['errors'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$template = WebhookPayloadTemplate::create([
|
||||
'uuid' => Str::uuid()->toString(),
|
||||
'workspace_id' => $workspace->id,
|
||||
'namespace_id' => $workspace->default_namespace_id ?? null,
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'format' => $validated['format'],
|
||||
'template' => $validated['template'],
|
||||
'is_default' => $validated['is_default'] ?? false,
|
||||
'is_active' => $validated['is_active'] ?? true,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->formatTemplate($template, true),
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing template.
|
||||
*/
|
||||
public function update(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()?->defaultHostWorkspace();
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['error' => 'Workspace not found'], 404);
|
||||
}
|
||||
|
||||
$template = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
|
||||
->where('uuid', $uuid)
|
||||
->first();
|
||||
|
||||
if (! $template) {
|
||||
return response()->json(['error' => 'Template not found'], 404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'sometimes|string|max:255',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'format' => 'sometimes|in:simple,mustache,json',
|
||||
'template' => 'sometimes|string|max:65535',
|
||||
'is_default' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
// Validate template syntax if template is being updated
|
||||
if (isset($validated['template'])) {
|
||||
$format = WebhookTemplateFormat::from($validated['format'] ?? $template->format->value);
|
||||
$validation = $this->templateService->validateTemplate($validated['template'], $format);
|
||||
|
||||
if (! $validation['valid']) {
|
||||
return response()->json([
|
||||
'error' => 'Invalid template',
|
||||
'errors' => $validation['errors'],
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
// Don't allow modifying builtin templates' format
|
||||
if ($template->isBuiltin()) {
|
||||
unset($validated['format']);
|
||||
}
|
||||
|
||||
$template->update($validated);
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->formatTemplate($template->fresh(), true),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a template.
|
||||
*/
|
||||
public function destroy(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()?->defaultHostWorkspace();
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['error' => 'Workspace not found'], 404);
|
||||
}
|
||||
|
||||
$template = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
|
||||
->where('uuid', $uuid)
|
||||
->first();
|
||||
|
||||
if (! $template) {
|
||||
return response()->json(['error' => 'Template not found'], 404);
|
||||
}
|
||||
|
||||
// Don't allow deleting builtin templates
|
||||
if ($template->isBuiltin()) {
|
||||
return response()->json(['error' => 'Built-in templates cannot be deleted'], 403);
|
||||
}
|
||||
|
||||
$template->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a template without saving.
|
||||
*/
|
||||
public function validate(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'format' => 'required|in:simple,mustache,json',
|
||||
'template' => 'required|string|max:65535',
|
||||
]);
|
||||
|
||||
$format = WebhookTemplateFormat::from($validated['format']);
|
||||
$validation = $this->templateService->validateTemplate($validated['template'], $format);
|
||||
|
||||
return response()->json([
|
||||
'valid' => $validation['valid'],
|
||||
'errors' => $validation['errors'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview a template with sample data.
|
||||
*/
|
||||
public function preview(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'format' => 'required|in:simple,mustache,json',
|
||||
'template' => 'required|string|max:65535',
|
||||
'event_type' => 'nullable|string|max:100',
|
||||
]);
|
||||
|
||||
$format = WebhookTemplateFormat::from($validated['format']);
|
||||
$result = $this->templateService->previewPayload(
|
||||
$validated['template'],
|
||||
$format,
|
||||
$validated['event_type'] ?? null
|
||||
);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate an existing template.
|
||||
*/
|
||||
public function duplicate(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()?->defaultHostWorkspace();
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['error' => 'Workspace not found'], 404);
|
||||
}
|
||||
|
||||
$template = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
|
||||
->where('uuid', $uuid)
|
||||
->first();
|
||||
|
||||
if (! $template) {
|
||||
return response()->json(['error' => 'Template not found'], 404);
|
||||
}
|
||||
|
||||
$newName = $request->input('name', $template->name.' (copy)');
|
||||
$duplicate = $template->duplicate($newName);
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->formatTemplate($duplicate, true),
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a template as the workspace default.
|
||||
*/
|
||||
public function setDefault(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()?->defaultHostWorkspace();
|
||||
|
||||
if (! $workspace) {
|
||||
return response()->json(['error' => 'Workspace not found'], 404);
|
||||
}
|
||||
|
||||
$template = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
|
||||
->where('uuid', $uuid)
|
||||
->first();
|
||||
|
||||
if (! $template) {
|
||||
return response()->json(['error' => 'Template not found'], 404);
|
||||
}
|
||||
|
||||
$template->setAsDefault();
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->formatTemplate($template->fresh(), true),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available template variables.
|
||||
*/
|
||||
public function variables(Request $request): JsonResponse
|
||||
{
|
||||
$eventType = $request->input('event_type');
|
||||
$variables = $this->templateService->getAvailableVariables($eventType);
|
||||
|
||||
return response()->json([
|
||||
'data' => $variables,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available template filters.
|
||||
*/
|
||||
public function filters(): JsonResponse
|
||||
{
|
||||
$filters = $this->templateService->getAvailableFilters();
|
||||
|
||||
return response()->json([
|
||||
'data' => $filters,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get builtin template definitions.
|
||||
*/
|
||||
public function builtins(): JsonResponse
|
||||
{
|
||||
$templates = $this->templateService->getBuiltinTemplates();
|
||||
|
||||
return response()->json([
|
||||
'data' => $templates,
|
||||
]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Protected Methods
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Format a template for API response.
|
||||
*/
|
||||
protected function formatTemplate(WebhookPayloadTemplate $template, bool $includeContent = false): array
|
||||
{
|
||||
$data = [
|
||||
'uuid' => $template->uuid,
|
||||
'name' => $template->name,
|
||||
'description' => $template->description,
|
||||
'format' => $template->format->value,
|
||||
'is_default' => $template->is_default,
|
||||
'is_active' => $template->is_active,
|
||||
'is_builtin' => $template->isBuiltin(),
|
||||
'builtin_type' => $template->builtin_type?->value,
|
||||
'created_at' => $template->created_at?->toIso8601String(),
|
||||
'updated_at' => $template->updated_at?->toIso8601String(),
|
||||
];
|
||||
|
||||
if ($includeContent) {
|
||||
$data['template'] = $template->template;
|
||||
$data['example_output'] = $template->example_output;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
144
src/Mod/Api/Enums/BuiltinTemplateType.php
Normal file
144
src/Mod/Api/Enums/BuiltinTemplateType.php
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Api\Enums;
|
||||
|
||||
/**
|
||||
* Built-in webhook template types.
|
||||
*
|
||||
* Pre-defined template configurations for common webhook destinations.
|
||||
*/
|
||||
enum BuiltinTemplateType: string
|
||||
{
|
||||
/**
|
||||
* Full event data - sends everything.
|
||||
*/
|
||||
case FULL = 'full';
|
||||
|
||||
/**
|
||||
* Minimal payload - essential fields only.
|
||||
*/
|
||||
case MINIMAL = 'minimal';
|
||||
|
||||
/**
|
||||
* Slack-formatted message.
|
||||
*/
|
||||
case SLACK = 'slack';
|
||||
|
||||
/**
|
||||
* Discord-formatted message.
|
||||
*/
|
||||
case DISCORD = 'discord';
|
||||
|
||||
/**
|
||||
* Get human-readable label for the type.
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::FULL => 'Full payload',
|
||||
self::MINIMAL => 'Minimal payload',
|
||||
self::SLACK => 'Slack message',
|
||||
self::DISCORD => 'Discord message',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get description for the type.
|
||||
*/
|
||||
public function description(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::FULL => 'Sends all event data in a structured format.',
|
||||
self::MINIMAL => 'Sends only essential fields: event type, ID, and timestamp.',
|
||||
self::SLACK => 'Formats payload for Slack incoming webhooks with blocks.',
|
||||
self::DISCORD => 'Formats payload for Discord webhooks with embeds.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default template content for this type.
|
||||
*/
|
||||
public function template(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::FULL => <<<'JSON'
|
||||
{
|
||||
"event": "{{event.type}}",
|
||||
"timestamp": "{{timestamp}}",
|
||||
"timestamp_unix": {{timestamp_unix}},
|
||||
"data": {{data | json}}
|
||||
}
|
||||
JSON,
|
||||
self::MINIMAL => <<<'JSON'
|
||||
{
|
||||
"event": "{{event.type}}",
|
||||
"id": "{{data.id}}",
|
||||
"timestamp": "{{timestamp}}"
|
||||
}
|
||||
JSON,
|
||||
self::SLACK => <<<'JSON'
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"type": "header",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "{{event.name}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "{{message}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "context",
|
||||
"elements": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "*Event:* `{{event.type}}` | *Time:* {{timestamp | iso8601}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON,
|
||||
self::DISCORD => <<<'JSON'
|
||||
{
|
||||
"embeds": [
|
||||
{
|
||||
"title": "{{event.name}}",
|
||||
"description": "{{message}}",
|
||||
"color": 5814783,
|
||||
"fields": [
|
||||
{
|
||||
"name": "Event Type",
|
||||
"value": "`{{event.type}}`",
|
||||
"inline": true
|
||||
},
|
||||
{
|
||||
"name": "ID",
|
||||
"value": "{{data.id | default:N/A}}",
|
||||
"inline": true
|
||||
}
|
||||
],
|
||||
"timestamp": "{{timestamp}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the template format for this type.
|
||||
*/
|
||||
public function format(): WebhookTemplateFormat
|
||||
{
|
||||
return WebhookTemplateFormat::JSON;
|
||||
}
|
||||
}
|
||||
73
src/Mod/Api/Enums/WebhookTemplateFormat.php
Normal file
73
src/Mod/Api/Enums/WebhookTemplateFormat.php
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Api\Enums;
|
||||
|
||||
/**
|
||||
* Webhook payload template formats.
|
||||
*
|
||||
* Defines supported template syntaxes for customising webhook payloads.
|
||||
*/
|
||||
enum WebhookTemplateFormat: string
|
||||
{
|
||||
/**
|
||||
* Simple variable substitution: {{variable.path}}
|
||||
*/
|
||||
case SIMPLE = 'simple';
|
||||
|
||||
/**
|
||||
* Mustache-style templates with conditionals and loops.
|
||||
*/
|
||||
case MUSTACHE = 'mustache';
|
||||
|
||||
/**
|
||||
* Raw JSON with variable interpolation.
|
||||
*/
|
||||
case JSON = 'json';
|
||||
|
||||
/**
|
||||
* Get human-readable label for the format.
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::SIMPLE => 'Simple (variable substitution)',
|
||||
self::MUSTACHE => 'Mustache (conditionals and loops)',
|
||||
self::JSON => 'JSON (structured template)',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get description for the format.
|
||||
*/
|
||||
public function description(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::SIMPLE => 'Basic {{variable}} replacement. Best for simple payloads.',
|
||||
self::MUSTACHE => 'Full Mustache syntax with {{#if}}, {{#each}}, and filters.',
|
||||
self::JSON => 'JSON template with embedded {{variables}}. Validates structure.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get example template for the format.
|
||||
*/
|
||||
public function example(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::SIMPLE => '{"event": "{{event.type}}", "id": "{{data.id}}"}',
|
||||
self::MUSTACHE => '{"event": "{{event.type}}"{{#if data.user}}, "user": "{{data.user.name}}"{{/if}}}',
|
||||
self::JSON => <<<'JSON'
|
||||
{
|
||||
"event": "{{event.type}}",
|
||||
"timestamp": "{{timestamp | iso8601}}",
|
||||
"data": {
|
||||
"id": "{{data.id}}",
|
||||
"name": "{{data.name}}"
|
||||
}
|
||||
}
|
||||
JSON,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
<?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
|
||||
{
|
||||
/**
|
||||
* API module tables.
|
||||
*
|
||||
* Creates tables for reusable webhook payload templates that can be
|
||||
* shared across different webhook configurations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::disableForeignKeyConstraints();
|
||||
|
||||
// Webhook Payload Templates
|
||||
// Reusable templates for customising webhook payload shapes
|
||||
Schema::create('api_webhook_payload_templates', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid('uuid')->unique();
|
||||
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
|
||||
$table->foreignId('namespace_id')->nullable()->constrained('namespaces')->nullOnDelete();
|
||||
|
||||
$table->string('name');
|
||||
$table->text('description')->nullable();
|
||||
|
||||
// Template format: simple, mustache, json
|
||||
$table->string('format', 20)->default('simple');
|
||||
|
||||
// The actual template content (JSON/Twig-like syntax)
|
||||
$table->text('template');
|
||||
|
||||
// Example rendered output for preview
|
||||
$table->text('example_output')->nullable();
|
||||
|
||||
// Template metadata
|
||||
$table->boolean('is_default')->default(false);
|
||||
$table->unsignedSmallInteger('sort_order')->default(0);
|
||||
$table->boolean('is_active')->default(true);
|
||||
|
||||
// Built-in template type (null for custom templates)
|
||||
// Values: full, minimal, slack, discord
|
||||
$table->string('builtin_type', 20)->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['workspace_id', 'is_active']);
|
||||
$table->index(['workspace_id', 'is_default']);
|
||||
$table->index(['workspace_id', 'sort_order']);
|
||||
$table->index('builtin_type');
|
||||
});
|
||||
|
||||
Schema::enableForeignKeyConstraints();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('api_webhook_payload_templates');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Add secret rotation grace period fields to webhook tables.
|
||||
*
|
||||
* This migration adds support for webhook secret rotation with a grace period,
|
||||
* allowing both old and new secrets to be accepted during the transition.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Add grace period fields to social_webhooks
|
||||
if (Schema::hasTable('social_webhooks')) {
|
||||
Schema::table('social_webhooks', function (Blueprint $table) {
|
||||
$table->text('previous_secret')->nullable()->after('secret');
|
||||
$table->timestamp('secret_rotated_at')->nullable()->after('previous_secret');
|
||||
$table->unsignedInteger('grace_period_seconds')->default(86400)->after('secret_rotated_at'); // 24 hours
|
||||
});
|
||||
}
|
||||
|
||||
// Add grace period fields to content_webhook_endpoints
|
||||
if (Schema::hasTable('content_webhook_endpoints')) {
|
||||
Schema::table('content_webhook_endpoints', function (Blueprint $table) {
|
||||
$table->text('previous_secret')->nullable()->after('secret');
|
||||
$table->timestamp('secret_rotated_at')->nullable()->after('previous_secret');
|
||||
$table->unsignedInteger('grace_period_seconds')->default(86400)->after('secret_rotated_at'); // 24 hours
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasTable('social_webhooks')) {
|
||||
Schema::table('social_webhooks', function (Blueprint $table) {
|
||||
$table->dropColumn(['previous_secret', 'secret_rotated_at', 'grace_period_seconds']);
|
||||
});
|
||||
}
|
||||
|
||||
if (Schema::hasTable('content_webhook_endpoints')) {
|
||||
Schema::table('content_webhook_endpoints', function (Blueprint $table) {
|
||||
$table->dropColumn(['previous_secret', 'secret_rotated_at', 'grace_period_seconds']);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
321
src/Mod/Api/Models/WebhookPayloadTemplate.php
Normal file
321
src/Mod/Api/Models/WebhookPayloadTemplate.php
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Api\Models;
|
||||
|
||||
use Core\Mod\Tenant\Concerns\BelongsToNamespace;
|
||||
use Core\Mod\Tenant\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
use Core\Mod\Api\Enums\BuiltinTemplateType;
|
||||
use Core\Mod\Api\Enums\WebhookTemplateFormat;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
|
||||
/**
|
||||
* Reusable webhook payload template.
|
||||
*
|
||||
* Allows users to define custom templates for webhook payloads,
|
||||
* supporting variable substitution, conditionals, and loops.
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $uuid
|
||||
* @property int $workspace_id
|
||||
* @property int|null $namespace_id
|
||||
* @property string $name
|
||||
* @property string|null $description
|
||||
* @property WebhookTemplateFormat $format
|
||||
* @property string $template
|
||||
* @property string|null $example_output
|
||||
* @property bool $is_default
|
||||
* @property int $sort_order
|
||||
* @property bool $is_active
|
||||
* @property BuiltinTemplateType|null $builtin_type
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
*/
|
||||
class WebhookPayloadTemplate extends Model
|
||||
{
|
||||
use BelongsToNamespace;
|
||||
use HasFactory;
|
||||
use LogsActivity;
|
||||
|
||||
protected $table = 'api_webhook_payload_templates';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'workspace_id',
|
||||
'namespace_id',
|
||||
'name',
|
||||
'description',
|
||||
'format',
|
||||
'template',
|
||||
'example_output',
|
||||
'is_default',
|
||||
'sort_order',
|
||||
'is_active',
|
||||
'builtin_type',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'format' => WebhookTemplateFormat::class,
|
||||
'is_default' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
'builtin_type' => BuiltinTemplateType::class,
|
||||
];
|
||||
|
||||
protected static function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function (WebhookPayloadTemplate $template) {
|
||||
if (empty($template->uuid)) {
|
||||
$template->uuid = (string) Str::uuid();
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure only one default template per workspace
|
||||
static::saving(function (WebhookPayloadTemplate $template) {
|
||||
if ($template->is_default) {
|
||||
static::where('workspace_id', $template->workspace_id)
|
||||
->where('id', '!=', $template->id ?? 0)
|
||||
->where('is_default', true)
|
||||
->update(['is_default' => false]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Relationships
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Scopes
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeDefault(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_default', true);
|
||||
}
|
||||
|
||||
public function scopeBuiltin(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNotNull('builtin_type');
|
||||
}
|
||||
|
||||
public function scopeCustom(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('builtin_type');
|
||||
}
|
||||
|
||||
public function scopeOrdered(Builder $query): Builder
|
||||
{
|
||||
return $query->orderBy('sort_order')->orderBy('name');
|
||||
}
|
||||
|
||||
public function scopeForWorkspace(Builder $query, int $workspaceId): Builder
|
||||
{
|
||||
return $query->where('workspace_id', $workspaceId);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// State Checks
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->is_active === true;
|
||||
}
|
||||
|
||||
public function isDefault(): bool
|
||||
{
|
||||
return $this->is_default === true;
|
||||
}
|
||||
|
||||
public function isBuiltin(): bool
|
||||
{
|
||||
return $this->builtin_type !== null;
|
||||
}
|
||||
|
||||
public function isCustom(): bool
|
||||
{
|
||||
return $this->builtin_type === null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Template Methods
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the template format enum.
|
||||
*/
|
||||
public function getFormat(): WebhookTemplateFormat
|
||||
{
|
||||
return $this->format ?? WebhookTemplateFormat::SIMPLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the builtin type if this is a builtin template.
|
||||
*/
|
||||
public function getBuiltinType(): ?BuiltinTemplateType
|
||||
{
|
||||
return $this->builtin_type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the example output preview.
|
||||
*/
|
||||
public function updateExampleOutput(string $output): void
|
||||
{
|
||||
$this->update(['example_output' => $output]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set this template as the workspace default.
|
||||
*/
|
||||
public function setAsDefault(): void
|
||||
{
|
||||
$this->update(['is_default' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate this template with a new name.
|
||||
*/
|
||||
public function duplicate(?string $newName = null): static
|
||||
{
|
||||
$duplicate = $this->replicate(['uuid', 'is_default']);
|
||||
$duplicate->uuid = (string) Str::uuid();
|
||||
$duplicate->name = $newName ?? $this->name.' (copy)';
|
||||
$duplicate->is_default = false;
|
||||
$duplicate->builtin_type = null; // Custom copy
|
||||
$duplicate->save();
|
||||
|
||||
return $duplicate;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Utilities
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'uuid';
|
||||
}
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logOnly(['name', 'description', 'format', 'template', 'is_default', 'is_active'])
|
||||
->logOnlyDirty()
|
||||
->dontSubmitEmptyLogs()
|
||||
->setDescriptionForEvent(fn (string $eventName) => "Webhook template {$eventName}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Flux badge colour for status.
|
||||
*/
|
||||
public function getStatusColorAttribute(): string
|
||||
{
|
||||
if (! $this->is_active) {
|
||||
return 'zinc';
|
||||
}
|
||||
|
||||
if ($this->is_default) {
|
||||
return 'green';
|
||||
}
|
||||
|
||||
return 'blue';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status label.
|
||||
*/
|
||||
public function getStatusLabelAttribute(): string
|
||||
{
|
||||
if (! $this->is_active) {
|
||||
return 'Inactive';
|
||||
}
|
||||
|
||||
if ($this->is_default) {
|
||||
return 'Default';
|
||||
}
|
||||
|
||||
return 'Active';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon for template type.
|
||||
*/
|
||||
public function getTypeIconAttribute(): string
|
||||
{
|
||||
if ($this->isBuiltin()) {
|
||||
return match ($this->builtin_type) {
|
||||
BuiltinTemplateType::SLACK => 'slack',
|
||||
BuiltinTemplateType::DISCORD => 'discord',
|
||||
BuiltinTemplateType::FULL => 'code-bracket',
|
||||
BuiltinTemplateType::MINIMAL => 'minus',
|
||||
default => 'document-text',
|
||||
};
|
||||
}
|
||||
|
||||
return 'document';
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Factory Methods
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create all builtin templates for a workspace.
|
||||
*/
|
||||
public static function createBuiltinTemplates(int $workspaceId, ?int $namespaceId = null): void
|
||||
{
|
||||
$sortOrder = 0;
|
||||
|
||||
foreach (BuiltinTemplateType::cases() as $type) {
|
||||
static::firstOrCreate(
|
||||
[
|
||||
'workspace_id' => $workspaceId,
|
||||
'builtin_type' => $type,
|
||||
],
|
||||
[
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'namespace_id' => $namespaceId,
|
||||
'name' => $type->label(),
|
||||
'description' => $type->description(),
|
||||
'format' => $type->format(),
|
||||
'template' => $type->template(),
|
||||
'is_default' => $type === BuiltinTemplateType::FULL,
|
||||
'sort_order' => $sortOrder++,
|
||||
'is_active' => true,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the default template for a workspace.
|
||||
*/
|
||||
public static function getDefaultForWorkspace(int $workspaceId): ?static
|
||||
{
|
||||
return static::forWorkspace($workspaceId)
|
||||
->active()
|
||||
->default()
|
||||
->first();
|
||||
}
|
||||
}
|
||||
19
src/Mod/Api/Routes/admin.php
Normal file
19
src/Mod/Api/Routes/admin.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Mod\Api\View\Modal\Admin\WebhookTemplateManager;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Admin Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Routes for the Api module's admin panel.
|
||||
|
|
||||
*/
|
||||
|
||||
Route::prefix('hub/api')->name('hub.api.')->group(function () {
|
||||
Route::get('/webhook-templates', WebhookTemplateManager::class)->name('webhook-templates');
|
||||
});
|
||||
308
src/Mod/Api/Services/WebhookSecretRotationService.php
Normal file
308
src/Mod/Api/Services/WebhookSecretRotationService.php
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Api\Services;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Service for managing webhook secret rotation with grace periods.
|
||||
*
|
||||
* Provides functionality for:
|
||||
* - Rotating webhook secrets while preserving the old secret during a grace period
|
||||
* - Verifying signatures against both current and previous secrets
|
||||
* - Cleaning up expired grace periods
|
||||
* - Getting the current rotation status
|
||||
*/
|
||||
class WebhookSecretRotationService
|
||||
{
|
||||
/**
|
||||
* Default grace period in seconds (24 hours).
|
||||
*/
|
||||
public const DEFAULT_GRACE_PERIOD = 86400;
|
||||
|
||||
/**
|
||||
* Minimum grace period in seconds (5 minutes).
|
||||
*/
|
||||
public const MIN_GRACE_PERIOD = 300;
|
||||
|
||||
/**
|
||||
* Maximum grace period in seconds (7 days).
|
||||
*/
|
||||
public const MAX_GRACE_PERIOD = 604800;
|
||||
|
||||
/**
|
||||
* Rotate the secret for a webhook model.
|
||||
*
|
||||
* Generates a new secret, stores the old one for the grace period,
|
||||
* and records the rotation timestamp.
|
||||
*
|
||||
* @param Model $webhook The webhook model (must have secret, previous_secret, secret_rotated_at fields)
|
||||
* @param int|null $gracePeriodSeconds Custom grace period (uses model's default if null)
|
||||
* @return string The new secret
|
||||
*/
|
||||
public function rotateSecret(Model $webhook, ?int $gracePeriodSeconds = null): string
|
||||
{
|
||||
$newSecret = Str::random(64);
|
||||
$currentSecret = $webhook->secret;
|
||||
|
||||
// Determine grace period
|
||||
$gracePeriod = $gracePeriodSeconds ?? $webhook->grace_period_seconds ?? self::DEFAULT_GRACE_PERIOD;
|
||||
$gracePeriod = max(self::MIN_GRACE_PERIOD, min(self::MAX_GRACE_PERIOD, $gracePeriod));
|
||||
|
||||
DB::transaction(function () use ($webhook, $currentSecret, $newSecret, $gracePeriod) {
|
||||
$webhook->update([
|
||||
'previous_secret' => $currentSecret,
|
||||
'secret' => $newSecret,
|
||||
'secret_rotated_at' => now(),
|
||||
'grace_period_seconds' => $gracePeriod,
|
||||
]);
|
||||
});
|
||||
|
||||
Log::info('Webhook secret rotated', [
|
||||
'webhook_id' => $webhook->id,
|
||||
'webhook_type' => class_basename($webhook),
|
||||
'grace_period_seconds' => $gracePeriod,
|
||||
]);
|
||||
|
||||
return $newSecret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a signature against both current and previous secrets during grace period.
|
||||
*
|
||||
* @param Model $webhook The webhook model
|
||||
* @param string $payload The raw payload to verify
|
||||
* @param string|null $signature The provided signature
|
||||
* @param string $algorithm Hash algorithm (default: sha256)
|
||||
* @return array{valid: bool, used_previous: bool, message: string}
|
||||
*/
|
||||
public function verifySignature(
|
||||
Model $webhook,
|
||||
string $payload,
|
||||
?string $signature,
|
||||
string $algorithm = 'sha256'
|
||||
): array {
|
||||
// If no secret configured, skip verification
|
||||
if (empty($webhook->secret)) {
|
||||
return [
|
||||
'valid' => true,
|
||||
'used_previous' => false,
|
||||
'message' => 'No secret configured, verification skipped',
|
||||
];
|
||||
}
|
||||
|
||||
// Signature required when secret is set
|
||||
if (empty($signature)) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'used_previous' => false,
|
||||
'message' => 'Signature required but not provided',
|
||||
];
|
||||
}
|
||||
|
||||
// Normalise signature (strip prefix like sha256= if present)
|
||||
$signature = $this->normaliseSignature($signature, $algorithm);
|
||||
|
||||
// Check against current secret
|
||||
$expectedSignature = hash_hmac($algorithm, $payload, $webhook->secret);
|
||||
if (hash_equals($expectedSignature, $signature)) {
|
||||
return [
|
||||
'valid' => true,
|
||||
'used_previous' => false,
|
||||
'message' => 'Signature verified with current secret',
|
||||
];
|
||||
}
|
||||
|
||||
// Check against previous secret if in grace period
|
||||
if ($this->isInGracePeriod($webhook) && ! empty($webhook->previous_secret)) {
|
||||
$previousExpectedSignature = hash_hmac($algorithm, $payload, $webhook->previous_secret);
|
||||
if (hash_equals($previousExpectedSignature, $signature)) {
|
||||
return [
|
||||
'valid' => true,
|
||||
'used_previous' => true,
|
||||
'message' => 'Signature verified with previous secret (grace period)',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => false,
|
||||
'used_previous' => false,
|
||||
'message' => 'Signature verification failed',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the webhook is currently in a grace period.
|
||||
*/
|
||||
public function isInGracePeriod(Model $webhook): bool
|
||||
{
|
||||
if (empty($webhook->secret_rotated_at)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$rotatedAt = Carbon::parse($webhook->secret_rotated_at);
|
||||
$gracePeriodSeconds = $webhook->grace_period_seconds ?? self::DEFAULT_GRACE_PERIOD;
|
||||
$graceEndsAt = $rotatedAt->copy()->addSeconds($gracePeriodSeconds);
|
||||
|
||||
return now()->isBefore($graceEndsAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the secret rotation status for a webhook.
|
||||
*
|
||||
* @return array{
|
||||
* has_previous_secret: bool,
|
||||
* in_grace_period: bool,
|
||||
* grace_period_seconds: int,
|
||||
* rotated_at: ?string,
|
||||
* grace_ends_at: ?string,
|
||||
* time_remaining_seconds: ?int,
|
||||
* time_remaining_human: ?string
|
||||
* }
|
||||
*/
|
||||
public function getSecretStatus(Model $webhook): array
|
||||
{
|
||||
$hasPreviousSecret = ! empty($webhook->previous_secret);
|
||||
$inGracePeriod = $this->isInGracePeriod($webhook);
|
||||
$gracePeriodSeconds = $webhook->grace_period_seconds ?? self::DEFAULT_GRACE_PERIOD;
|
||||
|
||||
$rotatedAt = $webhook->secret_rotated_at ? Carbon::parse($webhook->secret_rotated_at) : null;
|
||||
$graceEndsAt = $rotatedAt ? $rotatedAt->copy()->addSeconds($gracePeriodSeconds) : null;
|
||||
$timeRemaining = ($inGracePeriod && $graceEndsAt) ? now()->diffInSeconds($graceEndsAt, false) : null;
|
||||
$timeRemainingHuman = $timeRemaining > 0 ? $this->humanReadableTime($timeRemaining) : null;
|
||||
|
||||
return [
|
||||
'has_previous_secret' => $hasPreviousSecret,
|
||||
'in_grace_period' => $inGracePeriod,
|
||||
'grace_period_seconds' => $gracePeriodSeconds,
|
||||
'rotated_at' => $rotatedAt?->toIso8601String(),
|
||||
'grace_ends_at' => $graceEndsAt?->toIso8601String(),
|
||||
'time_remaining_seconds' => $timeRemaining > 0 ? (int) $timeRemaining : null,
|
||||
'time_remaining_human' => $timeRemainingHuman,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately invalidate the previous secret.
|
||||
*
|
||||
* Use this to end the grace period early (e.g., if the old secret was compromised).
|
||||
*/
|
||||
public function invalidatePreviousSecret(Model $webhook): void
|
||||
{
|
||||
$webhook->update([
|
||||
'previous_secret' => null,
|
||||
'secret_rotated_at' => null,
|
||||
]);
|
||||
|
||||
Log::info('Webhook previous secret invalidated', [
|
||||
'webhook_id' => $webhook->id,
|
||||
'webhook_type' => class_basename($webhook),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired grace periods for a specific model class.
|
||||
*
|
||||
* @param string $modelClass The webhook model class to clean up
|
||||
* @return int Number of webhooks cleaned up
|
||||
*/
|
||||
public function cleanupExpiredGracePeriods(string $modelClass): int
|
||||
{
|
||||
$count = 0;
|
||||
|
||||
$modelClass::query()
|
||||
->whereNotNull('previous_secret')
|
||||
->whereNotNull('secret_rotated_at')
|
||||
->chunkById(100, function ($webhooks) use (&$count) {
|
||||
foreach ($webhooks as $webhook) {
|
||||
if (! $this->isInGracePeriod($webhook)) {
|
||||
$webhook->update([
|
||||
'previous_secret' => null,
|
||||
'secret_rotated_at' => null,
|
||||
]);
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if ($count > 0) {
|
||||
Log::info('Cleaned up expired webhook secret grace periods', [
|
||||
'model_class' => $modelClass,
|
||||
'count' => $count,
|
||||
]);
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the grace period duration for a webhook.
|
||||
*/
|
||||
public function updateGracePeriod(Model $webhook, int $gracePeriodSeconds): void
|
||||
{
|
||||
$gracePeriodSeconds = max(self::MIN_GRACE_PERIOD, min(self::MAX_GRACE_PERIOD, $gracePeriodSeconds));
|
||||
|
||||
$webhook->update([
|
||||
'grace_period_seconds' => $gracePeriodSeconds,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a signature by removing common prefixes.
|
||||
*/
|
||||
protected function normaliseSignature(string $signature, string $algorithm): string
|
||||
{
|
||||
// Handle sha256=... format (GitHub, WordPress)
|
||||
$prefix = $algorithm.'=';
|
||||
if (str_starts_with($signature, $prefix)) {
|
||||
return substr($signature, strlen($prefix));
|
||||
}
|
||||
|
||||
return $signature;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert seconds to human-readable time string.
|
||||
*/
|
||||
protected function humanReadableTime(int $seconds): string
|
||||
{
|
||||
if ($seconds < 60) {
|
||||
return $seconds.' second'.($seconds !== 1 ? 's' : '');
|
||||
}
|
||||
|
||||
if ($seconds < 3600) {
|
||||
$minutes = (int) floor($seconds / 60);
|
||||
|
||||
return $minutes.' minute'.($minutes !== 1 ? 's' : '');
|
||||
}
|
||||
|
||||
if ($seconds < 86400) {
|
||||
$hours = (int) floor($seconds / 3600);
|
||||
$minutes = (int) floor(($seconds % 3600) / 60);
|
||||
|
||||
$result = $hours.' hour'.($hours !== 1 ? 's' : '');
|
||||
if ($minutes > 0) {
|
||||
$result .= ' '.$minutes.' minute'.($minutes !== 1 ? 's' : '');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$days = (int) floor($seconds / 86400);
|
||||
$hours = (int) floor(($seconds % 86400) / 3600);
|
||||
|
||||
$result = $days.' day'.($days !== 1 ? 's' : '');
|
||||
if ($hours > 0) {
|
||||
$result .= ' '.$hours.' hour'.($hours !== 1 ? 's' : '');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
629
src/Mod/Api/Services/WebhookTemplateService.php
Normal file
629
src/Mod/Api/Services/WebhookTemplateService.php
Normal file
|
|
@ -0,0 +1,629 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Api\Services;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Core\Mod\Api\Contracts\WebhookEvent;
|
||||
use Core\Mod\Api\Enums\BuiltinTemplateType;
|
||||
use Core\Mod\Api\Enums\WebhookTemplateFormat;
|
||||
use Core\Mod\Api\Models\WebhookPayloadTemplate;
|
||||
|
||||
/**
|
||||
* Service for rendering and validating webhook payload templates.
|
||||
*
|
||||
* Supports multiple template formats:
|
||||
* - Simple: Basic {{variable}} substitution
|
||||
* - Mustache: Conditionals and loops with {{#if}}, {{#each}}
|
||||
* - JSON: Structured JSON with embedded variables
|
||||
*/
|
||||
class WebhookTemplateService
|
||||
{
|
||||
/**
|
||||
* Available filters for template variables.
|
||||
*/
|
||||
protected const FILTERS = [
|
||||
'iso8601' => 'formatIso8601',
|
||||
'timestamp' => 'formatTimestamp',
|
||||
'currency' => 'formatCurrency',
|
||||
'json' => 'formatJson',
|
||||
'upper' => 'formatUpper',
|
||||
'lower' => 'formatLower',
|
||||
'default' => 'formatDefault',
|
||||
'truncate' => 'formatTruncate',
|
||||
'escape' => 'formatEscape',
|
||||
'urlencode' => 'formatUrlencode',
|
||||
];
|
||||
|
||||
/**
|
||||
* Render a template with the given event data.
|
||||
*
|
||||
* @param WebhookPayloadTemplate $template The template containing the pattern
|
||||
* @param WebhookEvent $event The event providing data
|
||||
* @return array The rendered payload
|
||||
*
|
||||
* @throws \InvalidArgumentException If template is invalid
|
||||
*/
|
||||
public function render(WebhookPayloadTemplate $template, WebhookEvent $event): array
|
||||
{
|
||||
$templateContent = $template->template;
|
||||
$format = $template->getFormat();
|
||||
$context = $this->buildContext($event);
|
||||
|
||||
return $this->renderTemplate($templateContent, $format, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a template string with context data.
|
||||
*
|
||||
* @param string $templateContent The template content
|
||||
* @param WebhookTemplateFormat $format The template format
|
||||
* @param array $context The context data
|
||||
* @return array The rendered payload
|
||||
*
|
||||
* @throws \InvalidArgumentException If template renders to invalid JSON
|
||||
*/
|
||||
public function renderTemplate(string $templateContent, WebhookTemplateFormat $format, array $context): array
|
||||
{
|
||||
$rendered = match ($format) {
|
||||
WebhookTemplateFormat::SIMPLE => $this->renderSimple($templateContent, $context),
|
||||
WebhookTemplateFormat::MUSTACHE => $this->renderMustache($templateContent, $context),
|
||||
WebhookTemplateFormat::JSON => $this->renderJson($templateContent, $context),
|
||||
};
|
||||
|
||||
// Parse as JSON if it's a string
|
||||
if (is_string($rendered)) {
|
||||
$decoded = json_decode($rendered, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \InvalidArgumentException('Template rendered to invalid JSON: '.json_last_error_msg());
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
return $rendered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the default payload structure for an event.
|
||||
*/
|
||||
public function buildDefaultPayload(WebhookEvent $event): array
|
||||
{
|
||||
return [
|
||||
'event' => $event::name(),
|
||||
'data' => $event->payload(),
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a template for syntax errors.
|
||||
*
|
||||
* @param string $template The template content
|
||||
* @param WebhookTemplateFormat $format The template format
|
||||
* @return array{valid: bool, errors: array<string>}
|
||||
*/
|
||||
public function validateTemplate(string $template, WebhookTemplateFormat $format): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
// Check for empty template
|
||||
if (empty(trim($template))) {
|
||||
return ['valid' => false, 'errors' => ['Template cannot be empty.']];
|
||||
}
|
||||
|
||||
// Format-specific validation
|
||||
$errors = match ($format) {
|
||||
WebhookTemplateFormat::SIMPLE => $this->validateSimple($template),
|
||||
WebhookTemplateFormat::MUSTACHE => $this->validateMustache($template),
|
||||
WebhookTemplateFormat::JSON => $this->validateJson($template),
|
||||
};
|
||||
|
||||
return [
|
||||
'valid' => empty($errors),
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available variables for an event type.
|
||||
*
|
||||
* @param string|null $eventType The event type (e.g., 'post.published')
|
||||
* @return array<string, array{type: string, description: string, example: mixed}>
|
||||
*/
|
||||
public function getAvailableVariables(?string $eventType = null): array
|
||||
{
|
||||
// Base variables available for all events
|
||||
$variables = [
|
||||
'event.type' => [
|
||||
'type' => 'string',
|
||||
'description' => 'The event identifier',
|
||||
'example' => $eventType ?? 'resource.action',
|
||||
],
|
||||
'event.name' => [
|
||||
'type' => 'string',
|
||||
'description' => 'Human-readable event name',
|
||||
'example' => 'Resource Updated',
|
||||
],
|
||||
'message' => [
|
||||
'type' => 'string',
|
||||
'description' => 'Human-readable event message',
|
||||
'example' => 'A resource was updated successfully.',
|
||||
],
|
||||
'timestamp' => [
|
||||
'type' => 'datetime',
|
||||
'description' => 'When the event occurred (ISO 8601)',
|
||||
'example' => now()->toIso8601String(),
|
||||
],
|
||||
'timestamp_unix' => [
|
||||
'type' => 'integer',
|
||||
'description' => 'Unix timestamp of the event',
|
||||
'example' => now()->timestamp,
|
||||
],
|
||||
'data' => [
|
||||
'type' => 'object',
|
||||
'description' => 'Event-specific data payload',
|
||||
'example' => ['id' => 1, 'name' => 'Example'],
|
||||
],
|
||||
'data.id' => [
|
||||
'type' => 'mixed',
|
||||
'description' => 'Primary identifier of the resource',
|
||||
'example' => 123,
|
||||
],
|
||||
'data.uuid' => [
|
||||
'type' => 'string',
|
||||
'description' => 'UUID of the resource (if available)',
|
||||
'example' => '550e8400-e29b-41d4-a716-446655440000',
|
||||
],
|
||||
];
|
||||
|
||||
return $variables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available filters for template variables.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getAvailableFilters(): array
|
||||
{
|
||||
return [
|
||||
'iso8601' => 'Format datetime as ISO 8601',
|
||||
'timestamp' => 'Format datetime as Unix timestamp',
|
||||
'currency' => 'Format number as currency (2 decimal places)',
|
||||
'json' => 'Encode value as JSON',
|
||||
'upper' => 'Convert to uppercase',
|
||||
'lower' => 'Convert to lowercase',
|
||||
'default' => 'Provide default value if empty (e.g., {{value | default:N/A}})',
|
||||
'truncate' => 'Truncate to specified length (e.g., {{text | truncate:100}})',
|
||||
'escape' => 'HTML escape the value',
|
||||
'urlencode' => 'URL encode the value',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview a template with sample data.
|
||||
*
|
||||
* @param string $template The template content
|
||||
* @param WebhookTemplateFormat $format The template format
|
||||
* @param string|null $eventType The event type for sample data
|
||||
* @return array{success: bool, output: mixed, errors: array<string>}
|
||||
*/
|
||||
public function previewPayload(string $template, WebhookTemplateFormat $format, ?string $eventType = null): array
|
||||
{
|
||||
// Validate first
|
||||
$validation = $this->validateTemplate($template, $format);
|
||||
if (! $validation['valid']) {
|
||||
return [
|
||||
'success' => false,
|
||||
'output' => null,
|
||||
'errors' => $validation['errors'],
|
||||
];
|
||||
}
|
||||
|
||||
// Build sample context
|
||||
$context = $this->buildSampleContext($eventType);
|
||||
|
||||
try {
|
||||
$rendered = match ($format) {
|
||||
WebhookTemplateFormat::SIMPLE => $this->renderSimple($template, $context),
|
||||
WebhookTemplateFormat::MUSTACHE => $this->renderMustache($template, $context),
|
||||
WebhookTemplateFormat::JSON => $this->renderJson($template, $context),
|
||||
};
|
||||
|
||||
// Parse as JSON
|
||||
if (is_string($rendered)) {
|
||||
$decoded = json_decode($rendered, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
return [
|
||||
'success' => false,
|
||||
'output' => $rendered,
|
||||
'errors' => ['Rendered template is not valid JSON: '.json_last_error_msg()],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'output' => $decoded,
|
||||
'errors' => [],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'output' => $rendered,
|
||||
'errors' => [],
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'output' => null,
|
||||
'errors' => [$e->getMessage()],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get builtin template content by type.
|
||||
*/
|
||||
public function getBuiltinTemplate(BuiltinTemplateType $type): string
|
||||
{
|
||||
return $type->template();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all builtin templates.
|
||||
*
|
||||
* @return array<string, array{name: string, description: string, template: string, format: WebhookTemplateFormat}>
|
||||
*/
|
||||
public function getBuiltinTemplates(): array
|
||||
{
|
||||
$templates = [];
|
||||
|
||||
foreach (BuiltinTemplateType::cases() as $type) {
|
||||
$templates[$type->value] = [
|
||||
'name' => $type->label(),
|
||||
'description' => $type->description(),
|
||||
'template' => $type->template(),
|
||||
'format' => $type->format(),
|
||||
];
|
||||
}
|
||||
|
||||
return $templates;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Protected Methods
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build template context from an event.
|
||||
*/
|
||||
protected function buildContext(WebhookEvent $event): array
|
||||
{
|
||||
return [
|
||||
'event' => [
|
||||
'type' => $event::name(),
|
||||
'name' => $event::nameLocalised(),
|
||||
],
|
||||
'data' => $event->payload(),
|
||||
'message' => $event->message(),
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'timestamp_unix' => now()->timestamp,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build sample context for preview.
|
||||
*/
|
||||
protected function buildSampleContext(?string $eventType): array
|
||||
{
|
||||
$variables = $this->getAvailableVariables($eventType);
|
||||
$context = [];
|
||||
|
||||
foreach ($variables as $path => $info) {
|
||||
Arr::set($context, $path, $info['example']);
|
||||
}
|
||||
|
||||
// Add message
|
||||
$context['message'] = 'Sample webhook event message';
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render simple template with variable substitution.
|
||||
*/
|
||||
protected function renderSimple(string $template, array $context): string
|
||||
{
|
||||
// Match {{variable}} or {{variable | filter}} or {{variable | filter:arg}}
|
||||
return preg_replace_callback(
|
||||
'/\{\{\s*([a-zA-Z0-9_\.]+)(?:\s*\|\s*([a-zA-Z0-9_]+)(?::([^\}]+))?)?\s*\}\}/',
|
||||
function ($matches) use ($context) {
|
||||
$path = $matches[1];
|
||||
$filter = $matches[2] ?? null;
|
||||
$filterArg = $matches[3] ?? null;
|
||||
|
||||
$value = Arr::get($context, $path);
|
||||
|
||||
// Apply filter if specified
|
||||
if ($filter && isset(self::FILTERS[$filter])) {
|
||||
$method = self::FILTERS[$filter];
|
||||
$value = $this->$method($value, $filterArg);
|
||||
}
|
||||
|
||||
// Convert arrays/objects to JSON strings
|
||||
if (is_array($value) || is_object($value)) {
|
||||
return json_encode($value);
|
||||
}
|
||||
|
||||
return (string) ($value ?? '');
|
||||
},
|
||||
$template
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render mustache-style template.
|
||||
*/
|
||||
protected function renderMustache(string $template, array $context): string
|
||||
{
|
||||
// Process conditionals: {{#if variable}}...{{/if}}
|
||||
$template = preg_replace_callback(
|
||||
'/\{\{#if\s+([a-zA-Z0-9_\.]+)\s*\}\}(.*?)\{\{\/if\}\}/s',
|
||||
function ($matches) use ($context) {
|
||||
$path = $matches[1];
|
||||
$content = $matches[2];
|
||||
$value = Arr::get($context, $path);
|
||||
|
||||
// Check if value is truthy
|
||||
if ($value && (! is_array($value) || ! empty($value))) {
|
||||
return $this->renderMustache($content, $context);
|
||||
}
|
||||
|
||||
return '';
|
||||
},
|
||||
$template
|
||||
);
|
||||
|
||||
// Process negative conditionals: {{#unless variable}}...{{/unless}}
|
||||
$template = preg_replace_callback(
|
||||
'/\{\{#unless\s+([a-zA-Z0-9_\.]+)\s*\}\}(.*?)\{\{\/unless\}\}/s',
|
||||
function ($matches) use ($context) {
|
||||
$path = $matches[1];
|
||||
$content = $matches[2];
|
||||
$value = Arr::get($context, $path);
|
||||
|
||||
// Check if value is falsy
|
||||
if (! $value || (is_array($value) && empty($value))) {
|
||||
return $this->renderMustache($content, $context);
|
||||
}
|
||||
|
||||
return '';
|
||||
},
|
||||
$template
|
||||
);
|
||||
|
||||
// Process loops: {{#each variable}}...{{/each}}
|
||||
$template = preg_replace_callback(
|
||||
'/\{\{#each\s+([a-zA-Z0-9_\.]+)\s*\}\}(.*?)\{\{\/each\}\}/s',
|
||||
function ($matches) use ($context) {
|
||||
$path = $matches[1];
|
||||
$content = $matches[2];
|
||||
$items = Arr::get($context, $path, []);
|
||||
|
||||
if (! is_array($items)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$output = '';
|
||||
foreach ($items as $index => $item) {
|
||||
$itemContext = array_merge($context, [
|
||||
'this' => $item,
|
||||
'@index' => $index,
|
||||
'@first' => $index === 0,
|
||||
'@last' => $index === count($items) - 1,
|
||||
]);
|
||||
|
||||
// Also allow direct access to item properties
|
||||
if (is_array($item)) {
|
||||
$itemContext = array_merge($itemContext, $item);
|
||||
}
|
||||
|
||||
$output .= $this->renderMustache($content, $itemContext);
|
||||
}
|
||||
|
||||
return $output;
|
||||
},
|
||||
$template
|
||||
);
|
||||
|
||||
// Finally, do simple variable replacement
|
||||
return $this->renderSimple($template, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render JSON template.
|
||||
*/
|
||||
protected function renderJson(string $template, array $context): string
|
||||
{
|
||||
// For JSON format, we do simple rendering then validate the result
|
||||
return $this->renderSimple($template, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate simple template syntax.
|
||||
*/
|
||||
protected function validateSimple(string $template): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
// Check for unclosed braces
|
||||
$openCount = substr_count($template, '{{');
|
||||
$closeCount = substr_count($template, '}}');
|
||||
|
||||
if ($openCount !== $closeCount) {
|
||||
$errors[] = 'Mismatched template braces. Found '.$openCount.' opening and '.$closeCount.' closing.';
|
||||
}
|
||||
|
||||
// Check for invalid variable names
|
||||
preg_match_all('/\{\{\s*([^}|]+)/', $template, $matches);
|
||||
foreach ($matches[1] as $varName) {
|
||||
$varName = trim($varName);
|
||||
// Allow #if, #unless, #each, /if, /unless, /each for mustache compatibility
|
||||
if (! preg_match('/^[#\/]?[a-zA-Z0-9_\.@]+$/', $varName)) {
|
||||
$errors[] = "Invalid variable name: {$varName}";
|
||||
}
|
||||
}
|
||||
|
||||
// Check for unknown filters
|
||||
preg_match_all('/\|\s*([a-zA-Z0-9_]+)/', $template, $filterMatches);
|
||||
foreach ($filterMatches[1] as $filter) {
|
||||
if (! isset(self::FILTERS[$filter])) {
|
||||
$errors[] = "Unknown filter: {$filter}. Available: ".implode(', ', array_keys(self::FILTERS));
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate mustache template syntax.
|
||||
*/
|
||||
protected function validateMustache(string $template): array
|
||||
{
|
||||
$errors = $this->validateSimple($template);
|
||||
|
||||
// Check for unclosed blocks
|
||||
$blocks = ['if', 'unless', 'each'];
|
||||
foreach ($blocks as $block) {
|
||||
$openCount = preg_match_all('/\{\{#'.$block.'\s/', $template);
|
||||
$closeCount = preg_match_all('/\{\{\/'.$block.'\}\}/', $template);
|
||||
|
||||
if ($openCount !== $closeCount) {
|
||||
$errors[] = "Unclosed {{#{$block}}} block. Found {$openCount} opening and {$closeCount} closing.";
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate JSON template syntax.
|
||||
*/
|
||||
protected function validateJson(string $template): array
|
||||
{
|
||||
$errors = $this->validateSimple($template);
|
||||
|
||||
// Try to parse as JSON after replacing variables with placeholders
|
||||
$testTemplate = preg_replace('/\{\{[^}]+\}\}/', '"__placeholder__"', $template);
|
||||
|
||||
json_decode($testTemplate);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$errors[] = 'Template is not valid JSON structure: '.json_last_error_msg();
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Filter methods
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
protected function formatIso8601(mixed $value, ?string $arg = null): string
|
||||
{
|
||||
if ($value instanceof Carbon) {
|
||||
return $value->toIso8601String();
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return Carbon::createFromTimestamp($value)->toIso8601String();
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
try {
|
||||
return Carbon::parse($value)->toIso8601String();
|
||||
} catch (\Exception) {
|
||||
return (string) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
protected function formatTimestamp(mixed $value, ?string $arg = null): int
|
||||
{
|
||||
if ($value instanceof Carbon) {
|
||||
return $value->timestamp;
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
try {
|
||||
return Carbon::parse($value)->timestamp;
|
||||
} catch (\Exception) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function formatCurrency(mixed $value, ?string $arg = null): string
|
||||
{
|
||||
$decimals = $arg ? (int) $arg : 2;
|
||||
|
||||
return number_format((float) $value, $decimals);
|
||||
}
|
||||
|
||||
protected function formatJson(mixed $value, ?string $arg = null): string
|
||||
{
|
||||
return json_encode($value) ?: '""';
|
||||
}
|
||||
|
||||
protected function formatUpper(mixed $value, ?string $arg = null): string
|
||||
{
|
||||
return mb_strtoupper((string) $value);
|
||||
}
|
||||
|
||||
protected function formatLower(mixed $value, ?string $arg = null): string
|
||||
{
|
||||
return mb_strtolower((string) $value);
|
||||
}
|
||||
|
||||
protected function formatDefault(mixed $value, ?string $arg = null): mixed
|
||||
{
|
||||
if ($value === null || $value === '' || (is_array($value) && empty($value))) {
|
||||
return $arg ?? '';
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
protected function formatTruncate(mixed $value, ?string $arg = null): string
|
||||
{
|
||||
$length = $arg ? (int) $arg : 100;
|
||||
$string = (string) $value;
|
||||
|
||||
if (mb_strlen($string) <= $length) {
|
||||
return $string;
|
||||
}
|
||||
|
||||
return mb_substr($string, 0, $length - 3).'...';
|
||||
}
|
||||
|
||||
protected function formatEscape(mixed $value, ?string $arg = null): string
|
||||
{
|
||||
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
}
|
||||
|
||||
protected function formatUrlencode(mixed $value, ?string $arg = null): string
|
||||
{
|
||||
return urlencode((string) $value);
|
||||
}
|
||||
}
|
||||
353
src/Mod/Api/View/Blade/admin/webhook-template-manager.blade.php
Normal file
353
src/Mod/Api/View/Blade/admin/webhook-template-manager.blade.php
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
<div class="space-y-6">
|
||||
{{-- Header --}}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<core:heading size="xl">Webhook templates</core:heading>
|
||||
<core:subheading>
|
||||
Reusable templates for customising webhook payload shapes.
|
||||
</core:subheading>
|
||||
</div>
|
||||
|
||||
<core:button wire:click="create" variant="primary" icon="plus">
|
||||
Create template
|
||||
</core:button>
|
||||
</div>
|
||||
|
||||
{{-- Filters and Search --}}
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<core:select wire:model.live="filter" class="w-40">
|
||||
<option value="all">All templates</option>
|
||||
<option value="custom">Custom only</option>
|
||||
<option value="builtin">Built-in only</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</core:select>
|
||||
</div>
|
||||
|
||||
<div class="w-full sm:w-64">
|
||||
<core:input
|
||||
wire:model.live.debounce.300ms="search"
|
||||
type="search"
|
||||
placeholder="Search templates..."
|
||||
icon="magnifying-glass"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Templates List --}}
|
||||
<flux:card>
|
||||
@if($this->templates->isEmpty())
|
||||
<div class="py-12 text-center">
|
||||
<core:icon name="document-text" class="mx-auto h-12 w-12 text-zinc-400" />
|
||||
<core:heading size="lg" class="mt-4">No templates found</core:heading>
|
||||
<core:subheading class="mt-2">
|
||||
@if($search)
|
||||
No templates match your search criteria.
|
||||
@else
|
||||
Create a custom template or use one of the built-in templates.
|
||||
@endif
|
||||
</core:subheading>
|
||||
</div>
|
||||
@else
|
||||
<div class="divide-y divide-zinc-200 dark:divide-zinc-700">
|
||||
@foreach($this->templates as $template)
|
||||
<div class="flex items-center justify-between p-4 hover:bg-zinc-50 dark:hover:bg-zinc-800/50">
|
||||
<div class="flex items-center gap-4">
|
||||
{{-- Icon --}}
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-zinc-100 dark:bg-zinc-800">
|
||||
<core:icon name="{{ $template->type_icon }}" class="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||
</div>
|
||||
|
||||
{{-- Info --}}
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-zinc-900 dark:text-white">{{ $template->name }}</span>
|
||||
@if($template->isBuiltin())
|
||||
<flux:badge color="purple" size="sm">Built-in</flux:badge>
|
||||
@endif
|
||||
@if($template->is_default)
|
||||
<flux:badge color="green" size="sm">Default</flux:badge>
|
||||
@endif
|
||||
@if(!$template->is_active)
|
||||
<flux:badge color="zinc" size="sm">Inactive</flux:badge>
|
||||
@endif
|
||||
</div>
|
||||
<p class="mt-0.5 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{{ $template->description ?? 'No description' }}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-zinc-400 dark:text-zinc-500">
|
||||
Format: {{ $template->format->label() }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Actions --}}
|
||||
<div class="flex items-center gap-2">
|
||||
<core:button wire:click="edit('{{ $template->uuid }}')" variant="ghost" size="sm" icon="pencil">
|
||||
Edit
|
||||
</core:button>
|
||||
|
||||
<flux:dropdown>
|
||||
<flux:button variant="ghost" size="sm" icon="ellipsis-vertical" />
|
||||
|
||||
<flux:menu>
|
||||
@if(!$template->is_default)
|
||||
<flux:menu.item wire:click="setDefault('{{ $template->uuid }}')" icon="star">
|
||||
Set as default
|
||||
</flux:menu.item>
|
||||
@endif
|
||||
|
||||
<flux:menu.item wire:click="duplicate('{{ $template->uuid }}')" icon="document-duplicate">
|
||||
Duplicate
|
||||
</flux:menu.item>
|
||||
|
||||
<flux:menu.item wire:click="toggleActive('{{ $template->uuid }}')" icon="{{ $template->is_active ? 'pause' : 'play' }}">
|
||||
{{ $template->is_active ? 'Disable' : 'Enable' }}
|
||||
</flux:menu.item>
|
||||
|
||||
@if(!$template->isBuiltin())
|
||||
<flux:menu.separator />
|
||||
<flux:menu.item wire:click="confirmDelete('{{ $template->uuid }}')" icon="trash" variant="danger">
|
||||
Delete
|
||||
</flux:menu.item>
|
||||
@endif
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Pagination --}}
|
||||
@if($this->templates->hasPages())
|
||||
<div class="border-t border-zinc-200 px-4 py-3 dark:border-zinc-700">
|
||||
{{ $this->templates->links() }}
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</flux:card>
|
||||
|
||||
{{-- Delete Confirmation Modal --}}
|
||||
@if($deletingId)
|
||||
<flux:modal wire:model="deletingId" class="max-w-md">
|
||||
<flux:heading size="lg">Delete template</flux:heading>
|
||||
<flux:subheading class="mt-2">
|
||||
Are you sure you want to delete this template? This action cannot be undone.
|
||||
</flux:subheading>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<core:button wire:click="cancelDelete" variant="ghost">
|
||||
Cancel
|
||||
</core:button>
|
||||
<core:button wire:click="delete" variant="danger">
|
||||
Delete
|
||||
</core:button>
|
||||
</div>
|
||||
</flux:modal>
|
||||
@endif
|
||||
|
||||
{{-- Editor Modal --}}
|
||||
@if($showEditor)
|
||||
<flux:modal wire:model="showEditor" class="max-w-5xl">
|
||||
<form wire:submit="save" class="space-y-6">
|
||||
<flux:heading size="lg">
|
||||
{{ $editingId ? 'Edit template' : 'Create template' }}
|
||||
</flux:heading>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{{-- Main editor (2/3 width) --}}
|
||||
<div class="lg:col-span-2 space-y-4">
|
||||
{{-- Name --}}
|
||||
<div>
|
||||
<core:label for="name">Template name</core:label>
|
||||
<core:input
|
||||
wire:model="name"
|
||||
id="name"
|
||||
placeholder="e.g. Slack notification"
|
||||
class="mt-1"
|
||||
/>
|
||||
@error('name')
|
||||
<core:text class="mt-1 text-sm text-red-600">{{ $message }}</core:text>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
{{-- Description --}}
|
||||
<div>
|
||||
<core:label for="description">Description</core:label>
|
||||
<core:input
|
||||
wire:model="description"
|
||||
id="description"
|
||||
placeholder="Brief description of this template"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{{-- Format selector --}}
|
||||
<div>
|
||||
<core:label for="format">Template format</core:label>
|
||||
<core:select wire:model.live="format" id="format" class="mt-1">
|
||||
@foreach($this->templateFormats as $value => $label)
|
||||
<option value="{{ $value }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</core:select>
|
||||
<core:text class="mt-1 text-xs text-zinc-500">
|
||||
{{ $this->templateFormatDescriptions[$format] ?? '' }}
|
||||
</core:text>
|
||||
</div>
|
||||
|
||||
{{-- Template textarea --}}
|
||||
<div
|
||||
x-data="{
|
||||
insertAtCursor(text) {
|
||||
const textarea = this.$refs.templateEditor;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const value = textarea.value;
|
||||
textarea.value = value.substring(0, start) + text + value.substring(end);
|
||||
textarea.selectionStart = textarea.selectionEnd = start + text.length;
|
||||
textarea.focus();
|
||||
$wire.set('template', textarea.value);
|
||||
}
|
||||
}"
|
||||
@insert-variable.window="insertAtCursor($event.detail.variable)"
|
||||
>
|
||||
<core:label for="template">Template content</core:label>
|
||||
<textarea
|
||||
x-ref="templateEditor"
|
||||
wire:model.blur="template"
|
||||
id="template"
|
||||
rows="12"
|
||||
class="mt-1 w-full rounded-lg border border-zinc-200 bg-zinc-50 p-3 font-mono text-sm dark:border-zinc-700 dark:bg-zinc-800"
|
||||
placeholder='{"event": "{{event.type}}", "data": {{data | json}}}'
|
||||
></textarea>
|
||||
@error('template')
|
||||
<core:text class="mt-1 text-sm text-red-600">{{ $message }}</core:text>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
{{-- Template errors --}}
|
||||
@if($templateErrors)
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-900/20">
|
||||
<p class="font-medium text-red-800 dark:text-red-200">Template errors:</p>
|
||||
<ul class="mt-1 list-inside list-disc text-sm text-red-700 dark:text-red-300">
|
||||
@foreach($templateErrors as $error)
|
||||
<li>{{ $error }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Action buttons --}}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<core:button type="button" wire:click="previewTemplate" variant="ghost" size="sm">
|
||||
Preview output
|
||||
</core:button>
|
||||
<core:button type="button" wire:click="validateTemplate" variant="ghost" size="sm">
|
||||
Validate
|
||||
</core:button>
|
||||
<core:button type="button" wire:click="resetTemplate" variant="ghost" size="sm">
|
||||
Reset to default
|
||||
</core:button>
|
||||
</div>
|
||||
|
||||
{{-- Preview output --}}
|
||||
@if($templatePreview)
|
||||
<div class="rounded-lg border border-green-200 bg-green-50 p-3 dark:border-green-800 dark:bg-green-900/20">
|
||||
<p class="mb-2 font-medium text-green-800 dark:text-green-200">Preview output:</p>
|
||||
<pre class="overflow-x-auto rounded bg-white p-2 text-xs text-zinc-800 dark:bg-zinc-900 dark:text-zinc-200">{{ json_encode($templatePreview, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}</pre>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Options --}}
|
||||
<div class="flex items-center gap-6">
|
||||
<label class="flex items-center gap-2">
|
||||
<core:checkbox wire:model="isDefault" />
|
||||
<span class="text-sm">Set as default template</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<core:checkbox wire:model="isActive" />
|
||||
<span class="text-sm">Template is active</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Sidebar (1/3 width) --}}
|
||||
<div class="space-y-4">
|
||||
{{-- Load from builtin --}}
|
||||
<div>
|
||||
<core:label>Load from built-in template</core:label>
|
||||
<div class="mt-2 space-y-1">
|
||||
@foreach($this->builtinTemplates as $type => $info)
|
||||
<button
|
||||
type="button"
|
||||
wire:click="loadBuiltinTemplate('{{ $type }}')"
|
||||
class="flex w-full items-center gap-2 rounded-lg border border-zinc-200 p-2 text-left text-sm transition hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-700/50"
|
||||
>
|
||||
<span class="font-medium">{{ $info['name'] }}</span>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Available variables --}}
|
||||
<div>
|
||||
<core:label>Available variables</core:label>
|
||||
<div class="mt-2 max-h-48 space-y-1 overflow-y-auto rounded-lg border border-zinc-200 bg-zinc-50 p-2 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
@foreach($this->availableVariables as $variable => $info)
|
||||
<button
|
||||
type="button"
|
||||
wire:click="insertVariable('{{ $variable }}')"
|
||||
class="flex w-full items-start gap-2 rounded p-1.5 text-left text-xs transition hover:bg-zinc-200 dark:hover:bg-zinc-700"
|
||||
title="{{ $info['description'] }}"
|
||||
>
|
||||
<code class="shrink-0 rounded bg-zinc-200 px-1 font-mono text-zinc-700 dark:bg-zinc-600 dark:text-zinc-200">{{ '{{' . $variable . '}}' }}</code>
|
||||
<span class="text-zinc-500 dark:text-zinc-400">{{ $info['type'] }}</span>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-zinc-500">Click to insert at cursor position.</p>
|
||||
</div>
|
||||
|
||||
{{-- Available filters --}}
|
||||
<div>
|
||||
<core:label>Available filters</core:label>
|
||||
<div class="mt-2 space-y-1 rounded-lg border border-zinc-200 bg-zinc-50 p-2 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
@foreach($this->availableFilters as $filter => $description)
|
||||
<div class="text-xs">
|
||||
<code class="rounded bg-zinc-200 px-1 font-mono text-zinc-700 dark:bg-zinc-600 dark:text-zinc-200">| {{ $filter }}</code>
|
||||
<span class="text-zinc-500 dark:text-zinc-400">{{ $description }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Syntax help --}}
|
||||
<div class="rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<p class="mb-2 text-xs font-medium text-zinc-700 dark:text-zinc-300">Syntax reference</p>
|
||||
<div class="space-y-1 text-xs text-zinc-600 dark:text-zinc-400">
|
||||
<p><code class="rounded bg-zinc-200 px-1 dark:bg-zinc-600">@{{ '{{variable}}' }}</code> - Simple value</p>
|
||||
<p><code class="rounded bg-zinc-200 px-1 dark:bg-zinc-600">@{{ '{{data.nested}}' }}</code> - Nested value</p>
|
||||
<p><code class="rounded bg-zinc-200 px-1 dark:bg-zinc-600">@{{ '{{value | filter}}' }}</code> - With filter</p>
|
||||
@if($format === 'mustache')
|
||||
<p><code class="rounded bg-zinc-200 px-1 dark:bg-zinc-600">@{{ '{{#if var}}...{{/if}}' }}</code> - Conditional</p>
|
||||
<p><code class="rounded bg-zinc-200 px-1 dark:bg-zinc-600">@{{ '{{#each arr}}...{{/each}}' }}</code> - Loop</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Footer actions --}}
|
||||
<div class="flex justify-end gap-3 border-t border-zinc-200 pt-4 dark:border-zinc-700">
|
||||
<core:button type="button" wire:click="closeEditor" variant="ghost">
|
||||
Cancel
|
||||
</core:button>
|
||||
<core:button type="submit" variant="primary">
|
||||
{{ $editingId ? 'Update template' : 'Create template' }}
|
||||
</core:button>
|
||||
</div>
|
||||
</form>
|
||||
</flux:modal>
|
||||
@endif
|
||||
</div>
|
||||
442
src/Mod/Api/View/Modal/Admin/WebhookTemplateManager.php
Normal file
442
src/Mod/Api/View/Modal/Admin/WebhookTemplateManager.php
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Api\View\Modal\Admin;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use Core\Mod\Api\Enums\BuiltinTemplateType;
|
||||
use Core\Mod\Api\Enums\WebhookTemplateFormat;
|
||||
use Core\Mod\Api\Models\WebhookPayloadTemplate;
|
||||
use Core\Mod\Api\Services\WebhookTemplateService;
|
||||
|
||||
#[Layout('hub::admin.layouts.app')]
|
||||
class WebhookTemplateManager extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
// List view state
|
||||
#[Url]
|
||||
public string $search = '';
|
||||
|
||||
#[Url]
|
||||
public string $filter = 'all'; // all, custom, builtin, active, inactive
|
||||
|
||||
public ?string $deletingId = null;
|
||||
|
||||
// Editor state
|
||||
public bool $showEditor = false;
|
||||
|
||||
public ?string $editingId = null;
|
||||
|
||||
#[Validate('required|string|max:255')]
|
||||
public string $name = '';
|
||||
|
||||
#[Validate('nullable|string|max:1000')]
|
||||
public string $description = '';
|
||||
|
||||
#[Validate('required|in:simple,mustache,json')]
|
||||
public string $format = 'json';
|
||||
|
||||
#[Validate('required|string|max:65535')]
|
||||
public string $template = '';
|
||||
|
||||
public bool $isDefault = false;
|
||||
|
||||
public bool $isActive = true;
|
||||
|
||||
// Preview state
|
||||
public ?array $templatePreview = null;
|
||||
|
||||
public ?array $templateErrors = null;
|
||||
|
||||
public string $previewEventType = 'resource.created';
|
||||
|
||||
#[Computed]
|
||||
public function workspace()
|
||||
{
|
||||
return auth()->user()?->defaultHostWorkspace();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function templates()
|
||||
{
|
||||
if (! $this->workspace) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$query = WebhookPayloadTemplate::where('workspace_id', $this->workspace->id);
|
||||
|
||||
// Apply search
|
||||
if ($this->search) {
|
||||
$escapedSearch = $this->escapeLikeWildcards($this->search);
|
||||
$query->where(function ($q) use ($escapedSearch) {
|
||||
$q->where('name', 'like', "%{$escapedSearch}%")
|
||||
->orWhere('description', 'like', "%{$escapedSearch}%");
|
||||
});
|
||||
}
|
||||
|
||||
// Apply filter
|
||||
$query = match ($this->filter) {
|
||||
'custom' => $query->custom(),
|
||||
'builtin' => $query->builtin(),
|
||||
'active' => $query->active(),
|
||||
'inactive' => $query->where('is_active', false),
|
||||
default => $query,
|
||||
};
|
||||
|
||||
return $query
|
||||
->ordered()
|
||||
->paginate(20);
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function templateFormats(): array
|
||||
{
|
||||
return [
|
||||
'simple' => WebhookTemplateFormat::SIMPLE->label(),
|
||||
'mustache' => WebhookTemplateFormat::MUSTACHE->label(),
|
||||
'json' => WebhookTemplateFormat::JSON->label(),
|
||||
];
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function templateFormatDescriptions(): array
|
||||
{
|
||||
return [
|
||||
'simple' => WebhookTemplateFormat::SIMPLE->description(),
|
||||
'mustache' => WebhookTemplateFormat::MUSTACHE->description(),
|
||||
'json' => WebhookTemplateFormat::JSON->description(),
|
||||
];
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function availableVariables(): array
|
||||
{
|
||||
$service = app(WebhookTemplateService::class);
|
||||
|
||||
return $service->getAvailableVariables($this->previewEventType);
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function availableFilters(): array
|
||||
{
|
||||
$service = app(WebhookTemplateService::class);
|
||||
|
||||
return $service->getAvailableFilters();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function builtinTemplates(): array
|
||||
{
|
||||
$service = app(WebhookTemplateService::class);
|
||||
|
||||
return $service->getBuiltinTemplates();
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
// Ensure builtin templates exist for this workspace
|
||||
if ($this->workspace) {
|
||||
WebhookPayloadTemplate::createBuiltinTemplates(
|
||||
$this->workspace->id,
|
||||
$this->workspace->default_namespace_id ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// List Actions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function confirmDelete(string $uuid): void
|
||||
{
|
||||
$this->deletingId = $uuid;
|
||||
}
|
||||
|
||||
public function cancelDelete(): void
|
||||
{
|
||||
$this->deletingId = null;
|
||||
}
|
||||
|
||||
public function delete(): void
|
||||
{
|
||||
if (! $this->deletingId || ! $this->workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
$template = WebhookPayloadTemplate::where('workspace_id', $this->workspace->id)
|
||||
->where('uuid', $this->deletingId)
|
||||
->first();
|
||||
|
||||
if ($template) {
|
||||
// Don't allow deleting builtin templates
|
||||
if ($template->isBuiltin()) {
|
||||
$this->dispatch('notify', type: 'error', message: 'Built-in templates cannot be deleted.');
|
||||
$this->deletingId = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$template->delete();
|
||||
$this->dispatch('notify', type: 'success', message: 'Template deleted.');
|
||||
unset($this->templates);
|
||||
}
|
||||
|
||||
$this->deletingId = null;
|
||||
}
|
||||
|
||||
public function toggleActive(string $uuid): void
|
||||
{
|
||||
if (! $this->workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
$template = WebhookPayloadTemplate::where('workspace_id', $this->workspace->id)
|
||||
->where('uuid', $uuid)
|
||||
->first();
|
||||
|
||||
if ($template) {
|
||||
$template->update(['is_active' => ! $template->is_active]);
|
||||
unset($this->templates);
|
||||
$this->dispatch('notify', type: 'success', message: $template->is_active ? 'Template enabled.' : 'Template disabled.');
|
||||
}
|
||||
}
|
||||
|
||||
public function setDefault(string $uuid): void
|
||||
{
|
||||
if (! $this->workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
$template = WebhookPayloadTemplate::where('workspace_id', $this->workspace->id)
|
||||
->where('uuid', $uuid)
|
||||
->first();
|
||||
|
||||
if ($template) {
|
||||
$template->setAsDefault();
|
||||
unset($this->templates);
|
||||
$this->dispatch('notify', type: 'success', message: 'Default template updated.');
|
||||
}
|
||||
}
|
||||
|
||||
public function duplicate(string $uuid): void
|
||||
{
|
||||
if (! $this->workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
$template = WebhookPayloadTemplate::where('workspace_id', $this->workspace->id)
|
||||
->where('uuid', $uuid)
|
||||
->first();
|
||||
|
||||
if ($template) {
|
||||
$template->duplicate();
|
||||
unset($this->templates);
|
||||
$this->dispatch('notify', type: 'success', message: 'Template duplicated.');
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Editor Actions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public function create(): void
|
||||
{
|
||||
$this->resetEditor();
|
||||
$this->template = $this->getDefaultTemplateContent();
|
||||
$this->showEditor = true;
|
||||
}
|
||||
|
||||
public function edit(string $uuid): void
|
||||
{
|
||||
if (! $this->workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
$template = WebhookPayloadTemplate::where('workspace_id', $this->workspace->id)
|
||||
->where('uuid', $uuid)
|
||||
->first();
|
||||
|
||||
if ($template) {
|
||||
$this->editingId = $uuid;
|
||||
$this->name = $template->name;
|
||||
$this->description = $template->description ?? '';
|
||||
$this->format = $template->format->value;
|
||||
$this->template = $template->template;
|
||||
$this->isDefault = $template->is_default;
|
||||
$this->isActive = $template->is_active;
|
||||
$this->templatePreview = null;
|
||||
$this->templateErrors = null;
|
||||
$this->showEditor = true;
|
||||
}
|
||||
}
|
||||
|
||||
public function closeEditor(): void
|
||||
{
|
||||
$this->showEditor = false;
|
||||
$this->resetEditor();
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
// Validate template first
|
||||
$this->validateTemplate();
|
||||
if (! empty($this->templateErrors)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validate();
|
||||
|
||||
if (! $this->workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'name' => $this->name,
|
||||
'description' => $this->description ?: null,
|
||||
'format' => $this->format,
|
||||
'template' => $this->template,
|
||||
'is_default' => $this->isDefault,
|
||||
'is_active' => $this->isActive,
|
||||
];
|
||||
|
||||
if ($this->editingId) {
|
||||
$template = WebhookPayloadTemplate::where('workspace_id', $this->workspace->id)
|
||||
->where('uuid', $this->editingId)
|
||||
->first();
|
||||
|
||||
if ($template) {
|
||||
// Don't allow modifying builtin templates' core properties
|
||||
if ($template->isBuiltin()) {
|
||||
unset($data['format']);
|
||||
}
|
||||
|
||||
$template->update($data);
|
||||
$this->dispatch('notify', type: 'success', message: 'Template updated.');
|
||||
}
|
||||
} else {
|
||||
$data['uuid'] = Str::uuid()->toString();
|
||||
$data['workspace_id'] = $this->workspace->id;
|
||||
$data['namespace_id'] = $this->workspace->default_namespace_id ?? null;
|
||||
|
||||
WebhookPayloadTemplate::create($data);
|
||||
$this->dispatch('notify', type: 'success', message: 'Template created.');
|
||||
}
|
||||
|
||||
unset($this->templates);
|
||||
$this->closeEditor();
|
||||
}
|
||||
|
||||
public function validateTemplate(): void
|
||||
{
|
||||
$this->templateErrors = null;
|
||||
|
||||
if (empty($this->template)) {
|
||||
$this->templateErrors = ['Template cannot be empty.'];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$service = app(WebhookTemplateService::class);
|
||||
$format = WebhookTemplateFormat::tryFrom($this->format) ?? WebhookTemplateFormat::SIMPLE;
|
||||
|
||||
$result = $service->validateTemplate($this->template, $format);
|
||||
|
||||
if (! $result['valid']) {
|
||||
$this->templateErrors = $result['errors'];
|
||||
}
|
||||
}
|
||||
|
||||
public function previewTemplate(): void
|
||||
{
|
||||
$this->templatePreview = null;
|
||||
$this->templateErrors = null;
|
||||
|
||||
if (empty($this->template)) {
|
||||
$this->templateErrors = ['Template cannot be empty.'];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$service = app(WebhookTemplateService::class);
|
||||
$format = WebhookTemplateFormat::tryFrom($this->format) ?? WebhookTemplateFormat::SIMPLE;
|
||||
|
||||
$result = $service->previewPayload($this->template, $format, $this->previewEventType);
|
||||
|
||||
if ($result['success']) {
|
||||
$this->templatePreview = $result['output'];
|
||||
$this->templateErrors = null;
|
||||
} else {
|
||||
$this->templatePreview = null;
|
||||
$this->templateErrors = $result['errors'];
|
||||
}
|
||||
}
|
||||
|
||||
public function insertVariable(string $variable): void
|
||||
{
|
||||
$this->dispatch('insert-variable', variable: '{{'.$variable.'}}');
|
||||
}
|
||||
|
||||
public function loadBuiltinTemplate(string $type): void
|
||||
{
|
||||
$builtinType = BuiltinTemplateType::tryFrom($type);
|
||||
if ($builtinType) {
|
||||
$this->template = $builtinType->template();
|
||||
$this->format = $builtinType->format()->value;
|
||||
$this->templatePreview = null;
|
||||
$this->templateErrors = null;
|
||||
}
|
||||
}
|
||||
|
||||
public function resetTemplate(): void
|
||||
{
|
||||
$this->template = $this->getDefaultTemplateContent();
|
||||
$this->templatePreview = null;
|
||||
$this->templateErrors = null;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('api::admin.webhook-template-manager');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Protected Methods
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
protected function resetEditor(): void
|
||||
{
|
||||
$this->editingId = null;
|
||||
$this->name = '';
|
||||
$this->description = '';
|
||||
$this->format = 'json';
|
||||
$this->template = '';
|
||||
$this->isDefault = false;
|
||||
$this->isActive = true;
|
||||
$this->templatePreview = null;
|
||||
$this->templateErrors = null;
|
||||
}
|
||||
|
||||
protected function getDefaultTemplateContent(): string
|
||||
{
|
||||
return <<<'JSON'
|
||||
{
|
||||
"event": "{{event.type}}",
|
||||
"timestamp": "{{timestamp}}",
|
||||
"data": {{data | json}}
|
||||
}
|
||||
JSON;
|
||||
}
|
||||
|
||||
protected function escapeLikeWildcards(string $value): string
|
||||
{
|
||||
return str_replace(['%', '_'], ['\\%', '\\_'], $value);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue