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:
Snider 2026-01-27 10:17:54 +00:00
parent 931974645b
commit 9cc9e4a178
15 changed files with 3270 additions and 0 deletions

View file

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

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

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

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

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

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

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

View file

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

View file

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

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

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

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

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

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

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