diff --git a/README.md b/README.md index 7a4a5770..ddeadd9f 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ ## What you get | 📅  **One calendar, every network** | Plan a month at a glance, drag any post to a new slot, and publish natively to 12 platforms. No redirects, no "finish in the mobile app." | | ✨  **An AI copilot that knows your brand** | Captions, hooks, full drafts, and multi-slide carousels in your tone, voice, and colors. It reads your brand profile on every generation. | | 🤖  **Built for AI agents** | A first-class MCP server and REST API. Claude, Cursor, ChatGPT, or your own scripts can draft, schedule, and publish for you. | -| ⚙️  **Automations that run themselves** | A visual workflow builder: triggers, conditions, RSS, webhooks, and AI generation, all server-side. Set it once, let it post. | +| ⚙️  **Automations that run themselves** | A visual workflow builder: triggers, conditions, RSS, HTTP requests, and AI generation, all server-side. Set it once, let it post. | | 🗂️  **Made for many clients** | Workspaces, roles, and approval flows so an agency or freelancer can run a roster of brands without the spreadsheets. | ## Features @@ -47,7 +47,7 @@ ## Features | **AI generate & review** | Draft from a prompt, get inline feedback before you publish. | | **AI carousel builder** | Prompt to a multi-slide carousel with images, on-brand. | | **Brand profile** | Tone, voice, language, and colors applied to every AI call. | -| **Automations** | Schedule / RSS triggers, conditions, publish steps, and webhooks. | +| **Automations** | Schedule / RSS triggers, conditions, publish steps, and HTTP requests. | | **Asset library** | Reusable workspace media, plus Unsplash and Giphy search built in. | | **Signatures & labels** | Reusable hashtag and CTA blocks, color-coded post tags. | | **Team collaboration** | Owner / Admin / Member roles, comments with @mentions on drafts. | diff --git a/app/Actions/Automation/Node/RunFetchRssNode.php b/app/Actions/Automation/Node/RunFetchRssNode.php index 36f04dd3..5b9866a8 100644 --- a/app/Actions/Automation/Node/RunFetchRssNode.php +++ b/app/Actions/Automation/Node/RunFetchRssNode.php @@ -24,7 +24,7 @@ * - When the fetch returns N new items, the current run takes item[0]; the * remaining N-1 items are spawned as sibling runs that resume at the node * immediately after this Fetch (with `context.fetched` already populated), - * so each item ends up generating its own Post / Webhook / etc. + * so each item ends up generating its own Post / etc. * - When the feed yields no new items, the result short-circuits via the * `no_items` handle; if the user hasn't wired anything to it, the run * completes silently (handled by AdvanceAutomationRun's default branch). diff --git a/app/Actions/Automation/Node/RunWebhookNode.php b/app/Actions/Automation/Node/RunWebhookNode.php deleted file mode 100644 index 5bf25090..00000000 --- a/app/Actions/Automation/Node/RunWebhookNode.php +++ /dev/null @@ -1,101 +0,0 @@ -resolverContext(); - $url = $this->resolver->resolve((string) data_get($config, 'url', ''), $context); - $method = strtoupper((string) data_get($config, 'method', HttpMethod::Post->value)); - - if ($url === '') { - return NodeRunResult::failed(__('automations.errors.webhook_missing_url'), [ - 'reason' => 'missing_url', - ]); - } - - try { - $this->safeHttp->guardAgainstSsrf($url); - } catch (RuntimeException) { - return NodeRunResult::failed(__('automations.errors.url_not_allowed'), [ - 'reason' => 'url_not_allowed', - 'url' => $url, - ]); - } - $headers = []; - - foreach ($config['headers'] ?? [] as $k => $v) { - $headers[$k] = $this->resolver->resolve((string) $v, $context); - } - - // Parse the template as JSON FIRST, then resolve placeholders in its - // string leaves — so a value containing `"`/`&`/newlines can't corrupt - // the JSON (the final json_encode escapes it). - $template = (string) data_get($config, 'payload_template', '{}'); - $trimmedTemplate = trim($template); - - if ($trimmedTemplate === '' || $trimmedTemplate === 'null') { - $payload = []; - } else { - $decodedTemplate = json_decode($template, true); - - if (json_last_error() !== JSON_ERROR_NONE) { - return NodeRunResult::failed(__('automations.errors.webhook_invalid_payload_json'), [ - 'reason' => 'invalid_payload_json', - ]); - } - - $payload = $this->resolver->resolveStructured($decodedTemplate ?? [], $context); - } - - if ($run->is_dry_run) { - return NodeRunResult::completed(output: [ - 'webhook' => ['method' => $method, 'url' => $url, 'dry_run' => true], - ]); - } - - try { - $response = Http::withHeaders($headers) - ->withUserAgent(config('trypost.user_agent')) - ->withOptions(['allow_redirects' => false]) - ->send($method, $url, ['json' => $payload]); - } catch (Throwable $e) { - return NodeRunResult::failed(__('automations.errors.webhook_request_failed'), [ - 'reason' => 'request_failed', - 'message' => $e->getMessage(), - ]); - } - - if ($response->serverError()) { - return NodeRunResult::failed(__('automations.errors.webhook_server_error'), [ - 'status' => $response->status(), - 'body' => substr($response->body(), 0, 500), - ]); - } - - return NodeRunResult::completed(output: [ - 'webhook' => [ - 'status' => $response->status(), - 'body' => substr($response->body(), 0, 500), - ], - ]); - } -} diff --git a/app/Actions/Post/UpdatePost.php b/app/Actions/Post/UpdatePost.php index 7bb2fac1..4f8de31c 100644 --- a/app/Actions/Post/UpdatePost.php +++ b/app/Actions/Post/UpdatePost.php @@ -25,26 +25,26 @@ public static function execute(Workspace $workspace, Post $post, array $data): a return ['post' => $post, 'action' => PostAction::Finalized]; } - $scheduledAt = $post->scheduled_at; - if (data_get($data, 'scheduled_at')) { - $scheduledAt = Carbon::parse(data_get($data, 'scheduled_at'))->utc(); - } + return DB::transaction(function () use ($post, $data): array { + $scheduledAt = $post->scheduled_at; + if (data_get($data, 'scheduled_at')) { + $scheduledAt = Carbon::parse(data_get($data, 'scheduled_at'))->utc(); + } - $status = data_get($data, 'status', $post->status); + $status = data_get($data, 'status', $post->status); - $post->update([ - 'content' => data_get($data, 'content', $post->content), - 'media' => data_get($data, 'media', $post->media), - 'status' => $status === PostStatus::Publishing->value ? PostStatus::Publishing : $status, - 'scheduled_at' => $scheduledAt, - ]); + $post->update([ + 'content' => data_get($data, 'content', $post->content), + 'media' => data_get($data, 'media', $post->media), + 'status' => $status === PostStatus::Publishing->value ? PostStatus::Publishing : $status, + 'scheduled_at' => $scheduledAt, + ]); - if (Arr::has($data, 'label_ids')) { - $post->labels()->sync(data_get($data, 'label_ids', [])); - } + if (Arr::has($data, 'label_ids')) { + $post->labels()->sync(data_get($data, 'label_ids', [])); + } - if (Arr::has($data, 'platforms')) { - DB::transaction(function () use ($post, $data) { + if (Arr::has($data, 'platforms')) { $post->postPlatforms()->update(['enabled' => false]); foreach (data_get($data, 'platforms', []) as $platformData) { @@ -69,20 +69,20 @@ public static function execute(Workspace $workspace, Post $post, array $data): a ->where('id', data_get($platformData, 'id')) ->update($updateData); } - }); - } + } - if ($status === PostStatus::Publishing->value) { - $post->update(['scheduled_at' => now()]); - PublishPost::dispatch($post); + if ($status === PostStatus::Publishing->value) { + $post->update(['scheduled_at' => now()]); + PublishPost::dispatch($post)->afterCommit(); - return ['post' => $post, 'action' => PostAction::Publishing]; - } + return ['post' => $post, 'action' => PostAction::Publishing]; + } - if ($status === PostStatus::Scheduled->value) { - return ['post' => $post, 'action' => PostAction::Scheduled]; - } + if ($status === PostStatus::Scheduled->value) { + return ['post' => $post, 'action' => PostAction::Scheduled]; + } - return ['post' => $post, 'action' => null]; + return ['post' => $post, 'action' => null]; + }); } } diff --git a/app/Broadcasting/WebhookLogChannel.php b/app/Broadcasting/WebhookLogChannel.php new file mode 100644 index 00000000..b3cc6102 --- /dev/null +++ b/app/Broadcasting/WebhookLogChannel.php @@ -0,0 +1,16 @@ +can('view', $webhook); + } +} diff --git a/app/Console/Commands/PruneWebhookLogs.php b/app/Console/Commands/PruneWebhookLogs.php new file mode 100644 index 00000000..64b2059f --- /dev/null +++ b/app/Console/Commands/PruneWebhookLogs.php @@ -0,0 +1,24 @@ +where('created_at', '<', now()->subDays(7)) + ->delete(); + + return self::SUCCESS; + } +} diff --git a/app/DataTransferObjects/MediaItem.php b/app/DataTransferObjects/MediaItem.php index 60718a1d..44beae7d 100644 --- a/app/DataTransferObjects/MediaItem.php +++ b/app/DataTransferObjects/MediaItem.php @@ -122,7 +122,7 @@ public static function fromArray(array $data): self $meta = data_get($data, 'meta'); return new self( - id: data_get($data, 'id', ''), + id: (string) data_get($data, 'id', ''), path: $path, url: data_get($data, 'url', ''), mime_type: $mimeType, diff --git a/app/Enums/Automation/HttpMethod.php b/app/Enums/Automation/HttpMethod.php index d9d841e8..473e2d73 100644 --- a/app/Enums/Automation/HttpMethod.php +++ b/app/Enums/Automation/HttpMethod.php @@ -5,7 +5,7 @@ namespace App\Enums\Automation; /** - * HTTP verbs available to the HTTP Request and Webhook nodes. Mirrors the + * HTTP verbs available to the HTTP Request node. Mirrors the * frontend HttpMethod const (resources/js/types/automation/http-method.ts). */ enum HttpMethod: string diff --git a/app/Enums/Automation/Node/Type.php b/app/Enums/Automation/Node/Type.php index 260ef03e..5d866c36 100644 --- a/app/Enums/Automation/Node/Type.php +++ b/app/Enums/Automation/Node/Type.php @@ -11,7 +11,6 @@ enum Type: string case Delay = 'delay'; case Condition = 'condition'; case Publish = 'publish'; - case Webhook = 'webhook'; case End = 'end'; case FetchRss = 'fetch_rss'; case HttpRequest = 'http_request'; diff --git a/app/Enums/Webhook/EventType.php b/app/Enums/Webhook/EventType.php new file mode 100644 index 00000000..862ea227 --- /dev/null +++ b/app/Enums/Webhook/EventType.php @@ -0,0 +1,30 @@ + self::PostScheduled, + PostStatus::Published => self::PostPublished, + PostStatus::PartiallyPublished => self::PostPartiallyPublished, + PostStatus::Failed => self::PostFailed, + PostStatus::Draft => $previous === PostStatus::Scheduled ? self::PostUnscheduled : null, + default => null, + }; + } +} diff --git a/app/Enums/Webhook/Status.php b/app/Enums/Webhook/Status.php new file mode 100644 index 00000000..324fe2de --- /dev/null +++ b/app/Enums/Webhook/Status.php @@ -0,0 +1,12 @@ + */ + public array $backoff = [5, 10]; + + public function __construct( + public WebhookLog $log, + ) {} + + /** + * @return array + */ + public function broadcastOn(): array + { + return [ + new PrivateChannel("webhook.{$this->log->webhook_id}.logs"), + ]; + } + + public function broadcastAs(): string + { + return 'webhook.log.updated'; + } + + public function broadcastQueue(): string + { + return 'broadcasts'; + } + + /** + * @return array + */ + public function broadcastWith(): array + { + return [ + 'id' => $this->log->id, + 'event_type' => $this->log->event_type, + 'payload' => $this->log->payload, + 'response_status' => $this->log->response_status, + 'response_body' => $this->log->response_body, + 'delivered_at' => $this->log->delivered_at?->toIso8601String(), + 'failed_at' => $this->log->failed_at?->toIso8601String(), + 'attempts' => $this->log->attempts, + 'created_at' => $this->log->created_at->toIso8601String(), + ]; + } +} diff --git a/app/Http/Controllers/App/WebhookController.php b/app/Http/Controllers/App/WebhookController.php new file mode 100644 index 00000000..2a1c686d --- /dev/null +++ b/app/Http/Controllers/App/WebhookController.php @@ -0,0 +1,179 @@ +user()->currentWorkspace; + + $this->authorize('viewAny', Webhook::class); + + $webhooks = Webhook::query() + ->where('workspace_id', $workspace->id) + ->orderByDesc('created_at') + ->get(); + + return Inertia::render('webhooks/Index', [ + 'webhooks' => $webhooks, + ]); + } + + public function show(Webhook $webhook): Response + { + $this->authorize('view', $webhook); + + return Inertia::render('webhooks/Show', [ + 'webhook' => $webhook->makeVisible('signing_secret'), + 'logs' => Inertia::scroll( + fn () => $webhook->logs()->orderByDesc('created_at')->paginate((int) config('app.pagination.default')), + ), + ]); + } + + public function store(StoreWebhookRequest $request, WebhookService $webhookService): RedirectResponse + { + $workspace = $request->user()->currentWorkspace; + + $this->authorize('create', Webhook::class); + + $validated = $request->validated(); + $endpoint = data_get($validated, 'endpoint'); + + if ($error = $this->endpointError($webhookService, $endpoint)) { + return $error; + } + + $webhook = Webhook::query()->create([ + 'workspace_id' => $workspace->id, + 'endpoint' => $endpoint, + 'events' => data_get($validated, 'events'), + 'status' => Status::Enabled, + 'signing_secret' => Webhook::generateSigningSecret(), + ]); + + session()->flash('flash.banner', __('webhooks.flash.created')); + session()->flash('flash.bannerStyle', 'success'); + + return redirect()->route('app.webhooks.show', $webhook); + } + + public function update(UpdateWebhookRequest $request, Webhook $webhook, WebhookService $webhookService): RedirectResponse + { + $this->authorize('update', $webhook); + + $validated = $request->validated(); + $endpoint = data_get($validated, 'endpoint'); + + if ($endpoint !== $webhook->endpoint && ($error = $this->endpointError($webhookService, $endpoint))) { + return $error; + } + + if (data_get($validated, 'status') === Status::Enabled->value) { + $validated['consecutive_failures'] = 0; + $validated['paused_at'] = null; + } + + $webhook->update($validated); + + session()->flash('flash.banner', __('webhooks.flash.updated')); + session()->flash('flash.bannerStyle', 'success'); + + return back(); + } + + public function sendTest(Webhook $webhook, WebhookService $webhookService): RedirectResponse + { + $this->authorize('update', $webhook); + + try { + $webhookService->ping($webhook->endpoint, $webhook->signing_secret); + } catch (RuntimeException $e) { + session()->flash('flash.banner', $e->getMessage()); + session()->flash('flash.bannerStyle', 'danger'); + + return back(); + } + + session()->flash('flash.banner', __('webhooks.flash.tested')); + session()->flash('flash.bannerStyle', 'success'); + + return back(); + } + + public function rotateSecret(Webhook $webhook): RedirectResponse + { + $this->authorize('update', $webhook); + + $webhook->update([ + 'signing_secret' => Webhook::generateSigningSecret(), + ]); + + session()->flash('flash.banner', __('webhooks.flash.secret_rotated')); + session()->flash('flash.bannerStyle', 'success'); + + return back(); + } + + public function replay(Webhook $webhook, WebhookLog $webhookLog): RedirectResponse + { + $this->authorize('replay', [$webhookLog, $webhook]); + + DispatchWebhook::dispatch( + $webhook, + $webhookLog->event_type, + data_get($webhookLog->payload, 'data') ?? [], + force: true, + ); + + session()->flash('flash.banner', __('webhooks.flash.replayed')); + session()->flash('flash.bannerStyle', 'success'); + + return back(); + } + + public function destroy(Webhook $webhook): RedirectResponse + { + $this->authorize('delete', $webhook); + + $webhook->delete(); + + session()->flash('flash.banner', __('webhooks.flash.deleted')); + session()->flash('flash.bannerStyle', 'success'); + + return redirect()->route('app.webhooks.index'); + } + + private function endpointError(WebhookService $webhookService, mixed $endpoint): ?RedirectResponse + { + if (! is_string($endpoint)) { + return null; + } + + try { + $webhookService->assertEndpointAllowed($endpoint); + } catch (RuntimeException $e) { + return back()->withErrors([ + 'endpoint' => $e->getMessage(), + ]); + } + + return null; + } +} diff --git a/app/Http/Requests/App/Automations/UpdateAutomationRequest.php b/app/Http/Requests/App/Automations/UpdateAutomationRequest.php index f89de16f..8ff80f85 100644 --- a/app/Http/Requests/App/Automations/UpdateAutomationRequest.php +++ b/app/Http/Requests/App/Automations/UpdateAutomationRequest.php @@ -70,9 +70,8 @@ public function rules(): array /** * Block saving a node whose config can't run: a Generate node whose image - * count doesn't fit a selected account's content-type, or a Webhook node - * whose payload template isn't valid JSON. Each issue is keyed to the field - * the frontend surfaces it under (mirrors the inline frontend validation). + * count doesn't fit a selected account's content-type. Each issue is keyed + * to the field the frontend surfaces it under. */ public function withValidator(Validator $validator): void { @@ -172,13 +171,6 @@ private function dataRulesForNodeType(?string $type, int $i): array 'mode' => ['required', Rule::in(array_column(PublishMode::cases(), 'value'))], 'scheduled_offset' => ['required_if:nodes.'.$i.'.data.mode,'.PublishMode::Scheduled->value, 'integer', 'min:0'], ], - NodeType::Webhook->value => [ - 'url' => ['required', new ResolvableUrl], - 'method' => ['required', Rule::in(array_column(HttpMethod::cases(), 'value'))], - 'payload_template' => ['nullable', 'string'], - 'headers' => ['nullable', 'array'], - 'headers.*' => ['string'], - ], NodeType::End->value => [ 'reason' => ['nullable', 'string'], ], diff --git a/app/Http/Requests/App/Webhook/StoreWebhookRequest.php b/app/Http/Requests/App/Webhook/StoreWebhookRequest.php new file mode 100644 index 00000000..cfb67279 --- /dev/null +++ b/app/Http/Requests/App/Webhook/StoreWebhookRequest.php @@ -0,0 +1,41 @@ + + */ + public function rules(): array + { + return [ + 'endpoint' => ['required', 'url', 'max:255'], + 'events' => ['required', 'array', 'min:1'], + 'events.*' => ['string', Rule::enum(EventType::class)], + ]; + } + + /** + * @return array + */ + public function attributes(): array + { + return [ + 'endpoint' => __('webhooks.create.endpoint'), + 'events' => __('webhooks.create.events'), + 'events.*' => __('webhooks.create.events'), + ]; + } +} diff --git a/app/Http/Requests/App/Webhook/UpdateWebhookRequest.php b/app/Http/Requests/App/Webhook/UpdateWebhookRequest.php new file mode 100644 index 00000000..1e818368 --- /dev/null +++ b/app/Http/Requests/App/Webhook/UpdateWebhookRequest.php @@ -0,0 +1,44 @@ + + */ + public function rules(): array + { + return [ + 'endpoint' => ['sometimes', 'url', 'max:255'], + 'events' => ['sometimes', 'array', 'min:1'], + 'events.*' => ['string', Rule::enum(EventType::class)], + 'status' => ['sometimes', 'string', Rule::enum(Status::class)->only([Status::Enabled, Status::Disabled])], + ]; + } + + /** + * @return array + */ + public function attributes(): array + { + return [ + 'endpoint' => __('webhooks.create.endpoint'), + 'events' => __('webhooks.create.events'), + 'events.*' => __('webhooks.create.events'), + 'status' => __('webhooks.table.status'), + ]; + } +} diff --git a/app/Jobs/Automation/ProcessAutomationNode.php b/app/Jobs/Automation/ProcessAutomationNode.php index d59d2844..85b48f5f 100644 --- a/app/Jobs/Automation/ProcessAutomationNode.php +++ b/app/Jobs/Automation/ProcessAutomationNode.php @@ -11,7 +11,6 @@ use App\Actions\Automation\Node\RunGenerateNode; use App\Actions\Automation\Node\RunHttpRequestNode; use App\Actions\Automation\Node\RunPublishNode; -use App\Actions\Automation\Node\RunWebhookNode; use App\Actions\Automation\Run\AdvanceAutomationRun; use App\DataTransferObjects\Automation\NodeRunResult; use App\Enums\Automation\Node\Type as NodeType; @@ -65,7 +64,17 @@ public function handle(AdvanceAutomationRun $advance): void return; } - $nodeType = NodeType::from($node['type']); + $nodeType = NodeType::tryFrom((string) data_get($node, 'type', '')); + + if ($nodeType === null) { + $this->run->update([ + 'status' => RunStatus::Failed, + 'error' => ['message' => __('automations.errors.node_no_longer_exists', ['node_id' => $this->nodeId])], + 'finished_at' => now(), + ]); + + return; + } $this->run->update([ 'status' => RunStatus::Running, @@ -143,7 +152,6 @@ private function executeNode(NodeType $type, array $config): NodeRunResult NodeType::Delay => app(RunDelayNode::class), NodeType::Condition => app(RunConditionNode::class), NodeType::Publish => app(RunPublishNode::class), - NodeType::Webhook => app(RunWebhookNode::class), NodeType::End => app(RunEndNode::class), NodeType::FetchRss => app(RunFetchRssNode::class), NodeType::HttpRequest => app(RunHttpRequestNode::class), diff --git a/app/Jobs/DispatchWebhook.php b/app/Jobs/DispatchWebhook.php new file mode 100644 index 00000000..d4198683 --- /dev/null +++ b/app/Jobs/DispatchWebhook.php @@ -0,0 +1,178 @@ + $payload + */ + public function __construct( + public Webhook $webhook, + public string $eventType, + public array $payload, + public bool $force = false, + ) { + $this->onQueue('webhooks'); + $this->logId = (string) Str::uuid(); + } + + public function handle(SafeHttpFetcher $safeHttp, WebhookService $webhooks): void + { + $this->webhook->refresh(); + + if (! $this->force && $this->webhook->status !== Status::Enabled) { + return; + } + + $body = [ + 'id' => $this->logId, + 'type' => $this->eventType, + 'data' => $this->payload, + 'created_at' => now()->toIso8601String(), + ]; + + $signature = $webhooks->signature($body, $this->webhook->signing_secret); + + $log = WebhookLog::query()->find($this->logId); + + if ($log) { + $log->update([ + 'payload' => $body, + 'attempts' => $this->attempts(), + 'failed_at' => null, + 'response_status' => null, + 'response_body' => null, + 'delivered_at' => null, + ]); + } else { + $log = new WebhookLog([ + 'webhook_id' => $this->webhook->id, + 'event_type' => $this->eventType, + 'payload' => $body, + 'attempts' => $this->attempts(), + ]); + $log->id = $this->logId; + $log->save(); + } + + try { + $safeHttp->guardAgainstSsrf($this->webhook->endpoint); + } catch (RuntimeException $e) { + $log->update([ + 'failed_at' => now(), + 'response_body' => $e->getMessage(), + ]); + + $log->refresh(); + LogUpdated::dispatch($log); + + throw $e; + } + + try { + $response = Http::timeout(10) + ->withUserAgent(config('trypost.user_agent')) + ->withOptions(['allow_redirects' => false]) + ->asJson() + ->withHeaders([ + 'X-Webhook-Signature' => $signature, + ]) + ->post($this->webhook->endpoint, $body); + + $responseBody = substr($response->body(), 0, 2000); + + if ($response->successful()) { + $log->update([ + 'response_status' => $response->status(), + 'response_body' => $responseBody, + 'delivered_at' => now(), + ]); + + $this->webhook->update(['last_sent_at' => now()]); + $this->webhook->resetConsecutiveFailures(); + + $log->refresh(); + LogUpdated::dispatch($log); + + return; + } + + $log->update([ + 'response_status' => $response->status(), + 'response_body' => $responseBody, + 'failed_at' => now(), + ]); + + $log->refresh(); + LogUpdated::dispatch($log); + + throw new RuntimeException("Webhook delivery failed with status: {$response->status()}"); + } catch (ConnectionException $e) { + $log->update([ + 'failed_at' => now(), + 'response_body' => $e->getMessage(), + ]); + + $log->refresh(); + LogUpdated::dispatch($log); + + throw $e; + } + } + + public function failed(?Throwable $exception): void + { + $this->webhook->refresh(); + + if ($this->webhook->status !== Status::Enabled) { + return; + } + + $this->webhook->increment('consecutive_failures'); + $this->webhook->refresh(); + + if ($this->webhook->consecutive_failures >= 5) { + $this->webhook->pause(); + + $owner = $this->webhook->workspace?->account?->owner; + + if ($owner?->email) { + Mail::to($owner->email)->send(new WebhookPausedMail($this->webhook)); + } + } + } +} diff --git a/app/Listeners/Webhook/SendPostCreatedWebhook.php b/app/Listeners/Webhook/SendPostCreatedWebhook.php new file mode 100644 index 00000000..2f2d1b89 --- /dev/null +++ b/app/Listeners/Webhook/SendPostCreatedWebhook.php @@ -0,0 +1,26 @@ +post; + $workspace = $post->workspace; + + if ($workspace === null) { + return; + } + + $this->webhooks->dispatch($workspace, EventType::PostCreated, $this->webhooks->postPayload($post)); + } +} diff --git a/app/Listeners/Webhook/SendPostDeletedWebhook.php b/app/Listeners/Webhook/SendPostDeletedWebhook.php new file mode 100644 index 00000000..d2905669 --- /dev/null +++ b/app/Listeners/Webhook/SendPostDeletedWebhook.php @@ -0,0 +1,29 @@ +find($event->workspaceId); + + if ($workspace === null) { + return; + } + + $this->webhooks->dispatch($workspace, EventType::PostDeleted, [ + 'id' => $event->postId, + 'workspace_id' => $event->workspaceId, + ]); + } +} diff --git a/app/Listeners/Webhook/SendPostStatusWebhook.php b/app/Listeners/Webhook/SendPostStatusWebhook.php new file mode 100644 index 00000000..df2f1c81 --- /dev/null +++ b/app/Listeners/Webhook/SendPostStatusWebhook.php @@ -0,0 +1,31 @@ +post->status, $event->previousStatus); + + if ($webhookEvent === null) { + return; + } + + $workspace = $event->post->workspace; + + if ($workspace === null) { + return; + } + + $this->webhooks->dispatch($workspace, $webhookEvent, $this->webhooks->postPayload($event->post)); + } +} diff --git a/app/Mail/WebhookPausedMail.php b/app/Mail/WebhookPausedMail.php new file mode 100644 index 00000000..4c803787 --- /dev/null +++ b/app/Mail/WebhookPausedMail.php @@ -0,0 +1,41 @@ + $this->webhook->endpoint]), + ); + } + + public function content(): Content + { + return new Content( + view: 'mail.webhook-paused', + with: [ + 'title' => __('webhooks.mail.paused_title'), + 'previewText' => __('webhooks.mail.paused_preview'), + 'body' => __('webhooks.mail.paused_body', ['endpoint' => $this->webhook->endpoint]), + 'buttonText' => __('webhooks.mail.paused_cta'), + 'url' => route('app.webhooks.show', $this->webhook), + ], + ); + } +} diff --git a/app/Models/Webhook.php b/app/Models/Webhook.php new file mode 100644 index 00000000..e3c759b8 --- /dev/null +++ b/app/Models/Webhook.php @@ -0,0 +1,90 @@ + */ + use HasFactory, HasUuids; + + protected $fillable = [ + 'workspace_id', + 'endpoint', + 'events', + 'status', + 'signing_secret', + 'consecutive_failures', + 'paused_at', + 'last_sent_at', + ]; + + protected $hidden = [ + 'signing_secret', + ]; + + protected $attributes = [ + 'consecutive_failures' => 0, + ]; + + protected function casts(): array + { + return [ + 'events' => 'array', + 'status' => Status::class, + 'signing_secret' => 'encrypted', + 'paused_at' => 'datetime', + 'last_sent_at' => 'datetime', + ]; + } + + public static function generateSigningSecret(): string + { + return 'whsec_'.Str::random(32); + } + + public function pause(): void + { + $this->update([ + 'status' => Status::Paused, + 'paused_at' => now(), + ]); + } + + public function resetConsecutiveFailures(): void + { + if ($this->consecutive_failures > 0) { + $this->update(['consecutive_failures' => 0]); + } + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeEnabled(Builder $query): Builder + { + return $query->where('status', Status::Enabled); + } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + public function logs(): HasMany + { + return $this->hasMany(WebhookLog::class); + } +} diff --git a/app/Models/WebhookLog.php b/app/Models/WebhookLog.php new file mode 100644 index 00000000..45b4124a --- /dev/null +++ b/app/Models/WebhookLog.php @@ -0,0 +1,44 @@ + */ + use HasFactory, HasUuids; + + protected $fillable = [ + 'webhook_id', + 'event_type', + 'payload', + 'response_status', + 'response_body', + 'delivered_at', + 'failed_at', + 'attempts', + ]; + + protected function casts(): array + { + return [ + 'payload' => 'array', + 'response_status' => 'integer', + 'delivered_at' => 'datetime', + 'failed_at' => 'datetime', + 'attempts' => 'integer', + ]; + } + + public function webhook(): BelongsTo + { + return $this->belongsTo(Webhook::class); + } +} diff --git a/app/Models/Workspace.php b/app/Models/Workspace.php index aba4944a..cb43f9aa 100644 --- a/app/Models/Workspace.php +++ b/app/Models/Workspace.php @@ -92,6 +92,11 @@ public function labels(): HasMany return $this->hasMany(WorkspaceLabel::class); } + public function webhooks(): HasMany + { + return $this->hasMany(Webhook::class); + } + /** * Get invites for this workspace (invites from the same account that include this workspace). * diff --git a/app/Observers/PostObserver.php b/app/Observers/PostObserver.php index e70713e9..3e067251 100644 --- a/app/Observers/PostObserver.php +++ b/app/Observers/PostObserver.php @@ -8,6 +8,7 @@ use App\Enums\Post\Status as PostStatus; use App\Events\OnboardingStatusUpdated; use App\Events\PostCreated; +use App\Events\PostStatusChanged; use App\Jobs\Automation\DispatchPostTriggerAutomationsJob; use App\Models\Account; use App\Models\Post; @@ -43,6 +44,25 @@ public function saved(Post $post): void if ($triggerType !== null) { DispatchPostTriggerAutomationsJob::dispatch($post, $triggerType)->afterCommit(); } + + $previousStatus = $this->previousStatus($post); + + DB::afterCommit(fn () => PostStatusChanged::dispatch($post, $previousStatus)); + } + + private function previousStatus(Post $post): ?PostStatus + { + $previous = $post->getRawOriginal('status'); + + if ($previous instanceof PostStatus) { + return $previous; + } + + if (is_string($previous)) { + return PostStatus::tryFrom($previous); + } + + return null; } /** diff --git a/app/Policies/WebhookLogPolicy.php b/app/Policies/WebhookLogPolicy.php new file mode 100644 index 00000000..2fd8537f --- /dev/null +++ b/app/Policies/WebhookLogPolicy.php @@ -0,0 +1,19 @@ +webhook_id === $webhook->id + && $webhook->workspace_id === $user->current_workspace_id + && $user->can('manageWebhooks', $user->currentWorkspace); + } +} diff --git a/app/Policies/WebhookPolicy.php b/app/Policies/WebhookPolicy.php new file mode 100644 index 00000000..1c0e1e8e --- /dev/null +++ b/app/Policies/WebhookPolicy.php @@ -0,0 +1,38 @@ +currentWorkspace !== null + && $user->can('manageWebhooks', $user->currentWorkspace); + } + + public function view(User $user, Webhook $webhook): bool + { + return $webhook->workspace_id === $user->current_workspace_id + && $user->can('manageWebhooks', $user->currentWorkspace); + } + + public function create(User $user): bool + { + return $this->viewAny($user); + } + + public function update(User $user, Webhook $webhook): bool + { + return $this->view($user, $webhook); + } + + public function delete(User $user, Webhook $webhook): bool + { + return $this->view($user, $webhook); + } +} diff --git a/app/Policies/WorkspacePolicy.php b/app/Policies/WorkspacePolicy.php index 1bf5d51d..6de17781 100644 --- a/app/Policies/WorkspacePolicy.php +++ b/app/Policies/WorkspacePolicy.php @@ -56,6 +56,11 @@ public function manageAccounts(User $user, Workspace $workspace): bool return $this->isOwnerOrWorkspaceAdmin($user, $workspace); } + public function manageWebhooks(User $user, Workspace $workspace): bool + { + return $this->isOwnerOrWorkspaceAdmin($user, $workspace); + } + public function createPost(User $user, Workspace $workspace): bool { if ($this->isOwner($user, $workspace)) { diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 12719148..d08d1999 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -25,6 +25,8 @@ use App\Models\Subscription; use App\Models\SubscriptionItem; use App\Models\User; +use App\Models\Webhook; +use App\Models\WebhookLog; use App\Models\Workspace; use App\Models\WorkspaceInvite; use App\Models\WorkspaceLabel; @@ -115,6 +117,8 @@ protected function configureMorphMap(): void 'subscription' => Subscription::class, 'subscriptionItem' => SubscriptionItem::class, 'user' => User::class, + 'webhook' => Webhook::class, + 'webhookLog' => WebhookLog::class, 'workspace' => Workspace::class, 'workspaceInvite' => WorkspaceInvite::class, 'workspaceLabel' => WorkspaceLabel::class, diff --git a/app/Services/Automation/AutomationConfigValidator.php b/app/Services/Automation/AutomationConfigValidator.php index aa0236a5..290beafb 100644 --- a/app/Services/Automation/AutomationConfigValidator.php +++ b/app/Services/Automation/AutomationConfigValidator.php @@ -17,7 +17,6 @@ final class AutomationConfigValidator { public function __construct( private GenerateNodeValidator $generateValidator, - private WebhookNodeValidator $webhookValidator, ) {} /** @@ -35,7 +34,6 @@ public function issues(array $nodes): array [$field, $message] = match (data_get($node, 'type')) { NodeType::Generate->value => ['accounts', $this->generateValidator->issueFor($config)], - NodeType::Webhook->value => ['payload_template', $this->webhookValidator->issueFor($config)], default => [null, null], }; diff --git a/app/Services/Automation/WebhookNodeValidator.php b/app/Services/Automation/WebhookNodeValidator.php deleted file mode 100644 index ab5b35bc..00000000 --- a/app/Services/Automation/WebhookNodeValidator.php +++ /dev/null @@ -1,36 +0,0 @@ - $config - */ - public function issueFor(array $config): ?string - { - $template = trim((string) data_get($config, 'payload_template', '')); - - if ($template === '' || $template === 'null') { - return null; - } - - json_decode($template); - - if (json_last_error() !== JSON_ERROR_NONE) { - return __('automations.errors.webhook_invalid_payload_json'); - } - - return null; - } -} diff --git a/app/Services/WebhookService.php b/app/Services/WebhookService.php new file mode 100644 index 00000000..453eba0a --- /dev/null +++ b/app/Services/WebhookService.php @@ -0,0 +1,229 @@ + $body + */ + public function signature(array $body, string $secret): string + { + return hash_hmac('sha256', json_encode($body), $secret); + } + + /** + * @throws RuntimeException + */ + public function assertEndpointAllowed(string $endpoint): void + { + try { + $this->safeHttp->guardAgainstSsrf($endpoint); + } catch (RuntimeException) { + throw new RuntimeException(__('webhooks.errors.endpoint_not_allowed')); + } + } + + /** + * @throws RuntimeException + */ + public function ping(string $endpoint, string $signingSecret): void + { + $this->assertEndpointAllowed($endpoint); + + $body = [ + 'id' => (string) Str::uuid(), + 'type' => 'webhook.test', + 'data' => (object) [], + 'created_at' => now()->toIso8601String(), + ]; + + try { + $response = Http::timeout(5) + ->withUserAgent(config('trypost.user_agent')) + ->withOptions(['allow_redirects' => false]) + ->asJson() + ->withHeaders([ + 'X-Webhook-Signature' => $this->signature($body, $signingSecret), + ]) + ->post($endpoint, $body); + } catch (Exception) { + throw new RuntimeException(__('webhooks.errors.endpoint_unreachable')); + } + + if (! $response->successful()) { + throw new RuntimeException(__('webhooks.errors.endpoint_http_status', [ + 'status' => $response->status(), + ])); + } + } + + /** + * @param array $payload + */ + public function dispatch(Workspace $workspace, WebhookEvent $event, array $payload): void + { + $webhooks = Webhook::query() + ->where('workspace_id', $workspace->id) + ->enabled() + ->get(); + + foreach ($webhooks as $webhook) { + if (in_array($event->value, $webhook->events ?? [], true)) { + DispatchWebhook::dispatch($webhook, $event->value, $payload); + } + } + } + + /** + * @return array + */ + public function postPayload(Post $post): array + { + $post->load(['user', 'workspace', 'labels', 'postPlatforms.socialAccount']); + + return [ + 'id' => $post->id, + 'workspace_id' => $post->workspace_id, + 'user_id' => $post->user_id, + 'status' => $post->status->value, + 'created_via' => $post->created_via?->value, + 'content' => $post->content, + 'scheduled_at' => $post->scheduled_at?->toIso8601String(), + 'published_at' => $post->published_at?->toIso8601String(), + 'created_at' => $post->created_at?->toIso8601String(), + 'updated_at' => $post->updated_at?->toIso8601String(), + 'author' => $this->authorPayload($post->user), + 'workspace' => $this->workspacePayload($post), + 'labels' => $post->labels + ->map(fn (WorkspaceLabel $label): array => [ + 'id' => $label->id, + 'name' => $label->name, + 'color' => $label->color, + ]) + ->values() + ->all(), + 'media' => $this->mediaPayload($post), + 'platforms' => $post->postPlatforms + ->map(fn (PostPlatform $platform): array => $this->platformPayload($platform)) + ->values() + ->all(), + ]; + } + + /** + * @return array{id: string, name: string}|null + */ + private function authorPayload(?User $user): ?array + { + if ($user === null) { + return null; + } + + return [ + 'id' => $user->id, + 'name' => $user->name, + ]; + } + + /** + * @return array{id: string, name: string|null} + */ + private function workspacePayload(Post $post): array + { + return [ + 'id' => $post->workspace_id, + 'name' => $post->workspace?->name, + ]; + } + + /** + * @return list> + */ + private function mediaPayload(Post $post): array + { + return collect($post->media ?? []) + ->map(function (mixed $item): array { + $media = MediaItem::fromArray(is_array($item) ? $item : []); + + return [ + 'id' => $media->id, + 'path' => $media->path, + 'url' => $media->url, + 'type' => Type::classify($media->mime_type, $media->path)?->value, + 'mime_type' => $media->mime_type, + 'original_filename' => $media->original_filename, + 'source' => $media->source?->value, + 'source_meta' => $media->source_meta, + 'meta' => $media->meta, + ]; + }) + ->values() + ->all(); + } + + /** + * @return array + */ + private function platformPayload(PostPlatform $platform): array + { + return [ + 'id' => $platform->id, + 'social_account_id' => $platform->social_account_id, + 'platform' => $platform->platform?->value, + 'content_type' => $platform->content_type?->value, + 'enabled' => $platform->enabled, + 'status' => $platform->status?->value, + 'platform_post_id' => $platform->platform_post_id, + 'platform_url' => $platform->platform_url, + 'published_at' => $platform->published_at?->toIso8601String(), + 'error_message' => $platform->error_message, + 'error_context' => $platform->error_context, + 'display_name' => $platform->display_name, + 'display_username' => $platform->display_username, + 'display_avatar' => $platform->display_avatar, + 'meta' => $platform->meta ?? [], + 'social_account' => $this->socialAccountPayload($platform->socialAccount), + ]; + } + + /** + * @return array{id: string, platform: string|null, display_name: string|null, username: string|null, is_active: bool, status: string|null}|null + */ + private function socialAccountPayload(?SocialAccount $account): ?array + { + if ($account === null) { + return null; + } + + return [ + 'id' => $account->id, + 'platform' => $account->platform?->value, + 'display_name' => $account->display_name, + 'username' => $account->username, + 'is_active' => $account->is_active, + 'status' => $account->status?->value, + ]; + } +} diff --git a/config/horizon.php b/config/horizon.php index f45602dd..45ec87b5 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -270,6 +270,21 @@ 'tries' => 1, 'nice' => 0, ], + + 'webhooks' => [ + 'connection' => 'redis', + 'queue' => ['webhooks'], + 'balance' => 'auto', + 'autoScalingStrategy' => 'time', + 'minProcesses' => 1, + 'maxProcesses' => 2, + 'timeout' => 60, + 'maxTime' => 0, + 'maxJobs' => 0, + 'memory' => 256, + 'tries' => 1, + 'nice' => 0, + ], ], 'environments' => [ @@ -297,6 +312,12 @@ 'balanceMaxShift' => 1, 'balanceCooldown' => 3, ], + + 'webhooks' => [ + 'maxProcesses' => 3, + 'balanceMaxShift' => 1, + 'balanceCooldown' => 3, + ], ], 'local' => [ @@ -315,6 +336,10 @@ 'automations' => [ 'maxProcesses' => 2, ], + + 'webhooks' => [ + 'maxProcesses' => 1, + ], ], ], ]; diff --git a/config/trypost.php b/config/trypost.php index bfad557d..d0288b99 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -146,7 +146,7 @@ |-------------------------------------------------------------------------- | | Branded User-Agent applied to outbound HTTP from automation nodes - | (webhook + http_request) so recipients know the request came from + | (http_request) and workspace webhooks so recipients know the request came from | TryPost.it. Self-hosters can override it. | */ diff --git a/database/factories/WebhookFactory.php b/database/factories/WebhookFactory.php new file mode 100644 index 00000000..9959faa6 --- /dev/null +++ b/database/factories/WebhookFactory.php @@ -0,0 +1,50 @@ + + */ +class WebhookFactory extends Factory +{ + protected $model = Webhook::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'workspace_id' => Workspace::factory(), + 'endpoint' => fake()->url(), + 'events' => [EventType::PostPublished->value, EventType::PostFailed->value], + 'status' => Status::Enabled, + 'signing_secret' => Webhook::generateSigningSecret(), + 'consecutive_failures' => 0, + ]; + } + + public function disabled(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => Status::Disabled, + ]); + } + + public function paused(): static + { + return $this->state(fn (array $attributes): array => [ + 'status' => Status::Paused, + 'consecutive_failures' => 5, + 'paused_at' => now(), + ]); + } +} diff --git a/database/factories/WebhookLogFactory.php b/database/factories/WebhookLogFactory.php new file mode 100644 index 00000000..1f23c1a1 --- /dev/null +++ b/database/factories/WebhookLogFactory.php @@ -0,0 +1,47 @@ + + */ +class WebhookLogFactory extends Factory +{ + protected $model = WebhookLog::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'webhook_id' => Webhook::factory(), + 'event_type' => EventType::PostPublished->value, + 'payload' => [ + 'type' => EventType::PostPublished->value, + 'data' => [], + ], + 'response_status' => 200, + 'response_body' => 'OK', + 'delivered_at' => now(), + 'attempts' => 1, + ]; + } + + public function failed(): static + { + return $this->state(fn (array $attributes): array => [ + 'response_status' => 500, + 'response_body' => 'Internal Server Error', + 'delivered_at' => null, + 'failed_at' => now(), + ]); + } +} diff --git a/database/migrations/2026_09_03_190941_create_webhooks_table.php b/database/migrations/2026_09_03_190941_create_webhooks_table.php new file mode 100644 index 00000000..21827fc7 --- /dev/null +++ b/database/migrations/2026_09_03_190941_create_webhooks_table.php @@ -0,0 +1,33 @@ +uuid('id')->primary(); + $table->foreignUuid('workspace_id')->constrained('workspaces')->cascadeOnDelete(); + $table->string('endpoint'); + $table->json('events'); + $table->string('status'); + $table->text('signing_secret'); + $table->unsignedInteger('consecutive_failures')->default(0); + $table->timestamp('paused_at')->nullable(); + $table->timestamp('last_sent_at')->nullable(); + $table->timestamps(); + + $table->index(['workspace_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('webhooks'); + } +}; diff --git a/database/migrations/2026_09_03_190942_create_webhook_logs_table.php b/database/migrations/2026_09_03_190942_create_webhook_logs_table.php new file mode 100644 index 00000000..5b941a6b --- /dev/null +++ b/database/migrations/2026_09_03_190942_create_webhook_logs_table.php @@ -0,0 +1,33 @@ +uuid('id')->primary(); + $table->foreignUuid('webhook_id')->constrained('webhooks')->cascadeOnDelete(); + $table->string('event_type'); + $table->json('payload')->nullable(); + $table->unsignedSmallInteger('response_status')->nullable(); + $table->text('response_body')->nullable(); + $table->timestamp('delivered_at')->nullable(); + $table->timestamp('failed_at')->nullable(); + $table->unsignedTinyInteger('attempts')->default(0); + $table->timestamps(); + + $table->index('webhook_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('webhook_logs'); + } +}; diff --git a/lang/ar/automations.php b/lang/ar/automations.php index b2ca859b..014134ff 100644 --- a/lang/ar/automations.php +++ b/lang/ar/automations.php @@ -62,7 +62,6 @@ 'delay' => 'تأخير', 'condition' => 'شرط', 'publish' => 'نشر', - 'webhook' => 'Webhook', 'end' => 'إنهاء', 'fetch_rss' => 'جلب RSS', 'http_request' => 'طلب HTTP', @@ -213,7 +212,6 @@ 'delay' => 'تأخير', 'condition' => 'شرط', 'publish' => 'نشر', - 'webhook' => 'Webhook', 'end' => 'إنهاء', 'end_summary' => 'يوقف الأتمتة هنا', 'fetch_rss' => 'جلب RSS', @@ -326,11 +324,6 @@ 'scheduled_offset' => 'الإزاحة عن المُشغّل (بالدقائق)', 'offset_summary' => ':mode · +:offset د', ], - 'webhook' => [ - 'url' => 'الرابط', - 'method' => 'الطريقة', - 'payload_template' => 'قالب الحمولة (JSON)', - ], 'end' => [ 'reason' => 'السبب (اختياري)', 'reason_placeholder' => 'مثال: تمت تصفيته بواسطة شرط', @@ -394,10 +387,6 @@ 'graph_contains_cycle' => 'يحتوي مخطط الأتمتة على حلقة.', 'only_failed_can_retry' => 'يمكن إعادة محاولة عمليات التشغيل الفاشلة فقط.', 'no_generated_post' => 'لم يتم العثور على منشور مُنشأ في التشغيل.', - 'webhook_server_error' => 'خطأ في خادم Webhook.', - 'webhook_request_failed' => 'تعذر إكمال طلب Webhook.', - 'webhook_missing_url' => 'عقدة Webhook تفتقد إلى رابط.', - 'webhook_invalid_payload_json' => 'قالب الحمولة ليس JSON صالحًا.', 'url_not_allowed' => 'رابط الطلب يشير إلى عنوان خاص أو غير قابل للوصول وتم حظره.', 'node_no_longer_exists' => 'العقدة :node_id لم تعد موجودة في الأتمتة.', 'no_trigger_connection' => 'لا توجد عقدة متصلة بعقدة المُشغّل.', diff --git a/lang/ar/sidebar.php b/lang/ar/sidebar.php index c3d592ec..2140c498 100644 --- a/lang/ar/sidebar.php +++ b/lang/ar/sidebar.php @@ -16,6 +16,7 @@ 'signatures' => 'التوقيعات', 'labels' => 'التسميات', 'assets' => 'الوسائط', + 'webhooks' => 'Webhooks', 'mcp' => 'MCP', ], 'language' => 'اللغة: :name', diff --git a/lang/ar/webhooks.php b/lang/ar/webhooks.php new file mode 100644 index 00000000..51a5469b --- /dev/null +++ b/lang/ar/webhooks.php @@ -0,0 +1,139 @@ + 'Webhooks', + 'description' => 'تلقَّ إشعارات فورية عند إنشاء المنشورات أو جدولتها أو إلغاء جدولتها أو نشرها أو فشلها.', + 'new' => 'إنشاء ويب هوك', + 'empty_title' => 'لا توجد ويب هوكس بعد', + 'empty_description' => 'أنشئ ويب هوكًا لتلقي إشعارات الأحداث فور حدوثها.', + 'table' => [ + 'endpoint' => 'Endpoint', + 'events' => 'يستمع إلى', + 'status' => 'الحالة', + 'last_sent' => 'آخر إرسال', + ], + 'events_count' => '{1} حدث واحد|[2,*] :count أحداث', + 'never' => 'أبدًا', + 'status' => [ + 'enabled' => 'مفعّل', + 'disabled' => 'معطّل', + 'paused' => 'متوقف مؤقتًا', + ], + 'actions' => [ + 'view' => 'عرض التفاصيل', + 'copy_id' => 'نسخ معرّف الويب هوك', + 'delete' => 'حذف', + 'edit' => 'تعديل الـ endpoint', + 'enable' => 'تفعيل الـ endpoint', + 'disable' => 'تعطيل الـ endpoint', + 'rotate' => 'تدوير سر التوقيع', + 'send_test' => 'إرسال حدث تجريبي', + 'replay' => 'إعادة الإرسال', + 'reveal_secret' => 'إظهار السر', + 'hide_secret' => 'إخفاء السر', + 'copy_secret' => 'نسخ السر', + ], + 'create' => [ + 'title' => 'إنشاء ويب هوك', + 'description' => 'اضبط endpoint لتلقي إشعارات الويب هوك.', + 'endpoint' => 'رابط الـ endpoint', + 'endpoint_placeholder' => 'https://example.com/webhooks', + 'events' => 'الأحداث', + 'events_placeholder' => 'اختر الأحداث...', + 'events_selected' => '{1} حدث واحد محدد|[2,*] :count أحداث محددة', + 'search_events' => 'البحث في الأحداث...', + 'no_events' => 'لم يُعثر على أحداث', + 'submit' => 'إنشاء ويب هوك', + 'cancel' => 'إلغاء', + ], + 'edit' => [ + 'title' => 'تعديل الـ endpoint', + 'description' => 'حدّث رابط الـ endpoint والأحداث المطلوب الاستماع إليها.', + 'submit' => 'حفظ التغييرات', + 'cancel' => 'إلغاء', + ], + 'delete' => [ + 'title' => 'حذف الويب هوك', + 'description' => 'هل تريد حذف هذا الويب هوك؟ لن تتلقى بعد الآن إشعارات الأحداث على هذا الـ endpoint.', + 'confirm' => 'حذف الويب هوك', + 'cancel' => 'إلغاء', + ], + 'rotate' => [ + 'title' => 'تدوير سر التوقيع', + 'description' => 'سيُنشأ سر توقيع جديد. يتوقف السر الحالي عن العمل فورًا. حدّث الـ endpoint لاستخدام السر الجديد.', + 'submit' => 'تدوير السر', + 'cancel' => 'إلغاء', + ], + 'show' => [ + 'signing_secret' => 'سر التوقيع', + 'last_sent' => 'آخر إرسال :time', + 'listening_for' => 'يستمع إلى', + 'http_status' => 'حالة HTTP', + 'status_code' => ':code - :reason', + 'attempts' => 'المحاولات', + 'delivered_at' => 'سُلِّم في', + 'response_body' => 'نص الاستجابة', + 'response' => 'الاستجابة', + 'no_response_body' => 'لا يوجد نص استجابة', + 'no_response' => 'لا توجد استجابة', + 'payload' => 'حمولة الرسالة', + 'empty_title' => 'لا توجد أحداث بعد', + 'empty_description' => 'عند إنشاء المنشورات أو جدولتها أو إلغاء جدولتها أو نشرها، ستظهر أحداث الويب هوك هنا.', + ], + 'events' => [ + 'group_posts' => 'المنشورات', + 'post_created' => 'تم إنشاء المنشور', + 'post_scheduled' => 'تمت جدولة المنشور', + 'post_unscheduled' => 'ألغيت جدولة المنشور', + 'post_published' => 'تم نشر المنشور', + 'post_partially_published' => 'نُشر المنشور جزئيًا', + 'post_failed' => 'فشل المنشور', + 'post_deleted' => 'تم حذف المنشور', + ], + 'http_reasons' => [ + 'unknown' => 'غير معروف', + '200' => 'OK', + '201' => 'تم الإنشاء', + '202' => 'مقبول', + '204' => 'لا محتوى', + '400' => 'طلب غير صالح', + '401' => 'غير مصرّح', + '403' => 'محظور', + '404' => 'غير موجود', + '408' => 'انتهت مهلة الطلب', + '422' => 'كيان غير قابل للمعالجة', + '429' => 'طلبات كثيرة جدًا', + '500' => 'خطأ داخلي في الخادم', + '502' => 'بوابة غير صالحة', + '503' => 'الخدمة غير متاحة', + '504' => 'انتهت مهلة البوابة', + ], + 'copied' => [ + 'id' => 'تم نسخ معرّف الويب هوك إلى الحافظة', + 'secret' => 'تم نسخ سر التوقيع إلى الحافظة', + 'response' => 'تم نسخ نص الاستجابة', + 'payload' => 'تم نسخ الحمولة', + ], + 'errors' => [ + 'endpoint_not_allowed' => 'هذا الـ endpoint غير مسموح به.', + 'endpoint_unreachable' => 'الـ endpoint غير قابل للوصول.', + 'endpoint_http_status' => 'أعاد الـ endpoint رمز HTTP :status.', + ], + 'flash' => [ + 'created' => 'تم إنشاء الويب هوك.', + 'updated' => 'تم تحديث الويب هوك.', + 'deleted' => 'تم حذف الويب هوك.', + 'secret_rotated' => 'تم تدوير سر التوقيع.', + 'replayed' => 'أُعيد إرسال حدث الويب هوك.', + 'tested' => 'أُرسل الحدث التجريبي.', + ], + 'mail' => [ + 'paused_subject' => 'تم إيقاف الويب هوك مؤقتًا: :endpoint', + 'paused_title' => 'أُوقف الويب هوك مؤقتًا بعد إخفاقات متكررة', + 'paused_preview' => 'أوقفنا ويب هوكًا مؤقتًا بعد 5 إخفاقات تسليم متتالية.', + 'paused_body' => 'أوقفنا الويب هوك على :endpoint مؤقتًا بعد 5 إخفاقات تسليم متتالية. راجع الـ endpoint وأعد تفعيله من صفحة تفاصيل الويب هوك.', + 'paused_cta' => 'عرض الويب هوك', + ], +]; diff --git a/lang/de/automations.php b/lang/de/automations.php index 528cbffe..67c28cc0 100644 --- a/lang/de/automations.php +++ b/lang/de/automations.php @@ -62,7 +62,6 @@ 'delay' => 'Verzögerung', 'condition' => 'Bedingung', 'publish' => 'Veröffentlichen', - 'webhook' => 'Webhook', 'end' => 'Ende', 'fetch_rss' => 'RSS abrufen', 'http_request' => 'HTTP-Anfrage', @@ -213,7 +212,6 @@ 'delay' => 'Verzögerung', 'condition' => 'Bedingung', 'publish' => 'Veröffentlichen', - 'webhook' => 'Webhook', 'end' => 'Ende', 'end_summary' => 'Stoppt die Automatisierung hier', 'fetch_rss' => 'RSS abrufen', @@ -326,11 +324,6 @@ 'scheduled_offset' => 'Versatz zum Trigger (Minuten)', 'offset_summary' => ':mode · +:offset Min.', ], - 'webhook' => [ - 'url' => 'URL', - 'method' => 'Methode', - 'payload_template' => 'Payload-Vorlage (JSON)', - ], 'end' => [ 'reason' => 'Grund (optional)', 'reason_placeholder' => 'z. B. Durch Bedingung herausgefiltert', @@ -394,10 +387,6 @@ 'graph_contains_cycle' => 'Der Automatisierungsgraph enthält einen Zyklus.', 'only_failed_can_retry' => 'Nur fehlgeschlagene Ausführungen können wiederholt werden.', 'no_generated_post' => 'Bei der Ausführung wurde kein generierter Beitrag gefunden.', - 'webhook_server_error' => 'Webhook-Serverfehler.', - 'webhook_request_failed' => 'Die Webhook-Anfrage konnte nicht abgeschlossen werden.', - 'webhook_missing_url' => 'Dem Webhook-Node fehlt eine URL.', - 'webhook_invalid_payload_json' => 'Die Payload-Vorlage ist kein gültiges JSON.', 'url_not_allowed' => 'Die Anfrage-URL verweist auf eine private oder nicht erreichbare Adresse und wurde blockiert.', 'node_no_longer_exists' => 'Node :node_id existiert in der Automatisierung nicht mehr.', 'no_trigger_connection' => 'Kein Node mit dem Trigger-Node verbunden.', diff --git a/lang/de/sidebar.php b/lang/de/sidebar.php index 06c41f1c..338d3361 100644 --- a/lang/de/sidebar.php +++ b/lang/de/sidebar.php @@ -16,6 +16,7 @@ 'signatures' => 'Signaturen', 'labels' => 'Labels', 'assets' => 'Assets', + 'webhooks' => 'Webhooks', 'mcp' => 'MCP', ], 'language' => 'Sprache: :name', diff --git a/lang/de/webhooks.php b/lang/de/webhooks.php new file mode 100644 index 00000000..f8dead81 --- /dev/null +++ b/lang/de/webhooks.php @@ -0,0 +1,139 @@ + 'Webhooks', + 'description' => 'Erhalte Echtzeit-Benachrichtigungen, wenn Beiträge erstellt, geplant, deren Planung aufgehoben, veröffentlicht werden oder fehlschlagen.', + 'new' => 'Webhook erstellen', + 'empty_title' => 'Noch keine Webhooks', + 'empty_description' => 'Erstelle einen Webhook, um Ereignisbenachrichtigungen in Echtzeit zu empfangen.', + 'table' => [ + 'endpoint' => 'Endpoint', + 'events' => 'Hört auf', + 'status' => 'Status', + 'last_sent' => 'Zuletzt gesendet', + ], + 'events_count' => '{1} :count Ereignis|[2,*] :count Ereignisse', + 'never' => 'Nie', + 'status' => [ + 'enabled' => 'Aktiv', + 'disabled' => 'Deaktiviert', + 'paused' => 'Pausiert', + ], + 'actions' => [ + 'view' => 'Details anzeigen', + 'copy_id' => 'Webhook-ID kopieren', + 'delete' => 'Löschen', + 'edit' => 'Endpoint bearbeiten', + 'enable' => 'Endpoint aktivieren', + 'disable' => 'Endpoint deaktivieren', + 'rotate' => 'Signatur-Secret rotieren', + 'send_test' => 'Test-Ereignis senden', + 'replay' => 'Erneut senden', + 'reveal_secret' => 'Secret anzeigen', + 'hide_secret' => 'Secret ausblenden', + 'copy_secret' => 'Secret kopieren', + ], + 'create' => [ + 'title' => 'Webhook erstellen', + 'description' => 'Konfiguriere einen Endpoint, um Webhook-Benachrichtigungen zu empfangen.', + 'endpoint' => 'Endpoint-URL', + 'endpoint_placeholder' => 'https://example.com/webhooks', + 'events' => 'Ereignisse', + 'events_placeholder' => 'Ereignisse auswählen...', + 'events_selected' => '{1} :count Ereignis ausgewählt|[2,*] :count Ereignisse ausgewählt', + 'search_events' => 'Ereignisse suchen...', + 'no_events' => 'Keine Ereignisse gefunden', + 'submit' => 'Webhook erstellen', + 'cancel' => 'Abbrechen', + ], + 'edit' => [ + 'title' => 'Endpoint bearbeiten', + 'description' => 'Aktualisiere die Endpoint-URL und die Ereignisse, auf die gehört wird.', + 'submit' => 'Änderungen speichern', + 'cancel' => 'Abbrechen', + ], + 'delete' => [ + 'title' => 'Webhook löschen', + 'description' => 'Möchtest du diesen Webhook wirklich löschen? Du erhältst an diesem Endpoint keine Ereignisbenachrichtigungen mehr.', + 'confirm' => 'Webhook löschen', + 'cancel' => 'Abbrechen', + ], + 'rotate' => [ + 'title' => 'Signatur-Secret rotieren', + 'description' => 'Dadurch wird ein neues Signatur-Secret erzeugt. Das aktuelle Secret funktioniert sofort nicht mehr. Aktualisiere deinen Endpoint auf das neue Secret.', + 'submit' => 'Secret rotieren', + 'cancel' => 'Abbrechen', + ], + 'show' => [ + 'signing_secret' => 'Signatur-Secret', + 'last_sent' => 'Zuletzt gesendet :time', + 'listening_for' => 'Hört auf', + 'http_status' => 'HTTP-Status', + 'status_code' => ':code - :reason', + 'attempts' => 'Versuche', + 'delivered_at' => 'Zugestellt um', + 'response_body' => 'Antworttext', + 'response' => 'Antwort', + 'no_response_body' => 'Kein Antworttext', + 'no_response' => 'Keine Antwort', + 'payload' => 'Nachrichten-Payload', + 'empty_title' => 'Noch keine Webhook-Ereignisse', + 'empty_description' => 'Sobald Beiträge erstellt, geplant, deren Planung aufgehoben oder veröffentlicht werden, siehst du die Webhook-Ereignisse hier.', + ], + 'events' => [ + 'group_posts' => 'Beiträge', + 'post_created' => 'Beitrag erstellt', + 'post_scheduled' => 'Beitrag geplant', + 'post_unscheduled' => 'Planung aufgehoben', + 'post_published' => 'Beitrag veröffentlicht', + 'post_partially_published' => 'Beitrag teilweise veröffentlicht', + 'post_failed' => 'Beitrag fehlgeschlagen', + 'post_deleted' => 'Beitrag gelöscht', + ], + 'http_reasons' => [ + 'unknown' => 'Unbekannt', + '200' => 'OK', + '201' => 'Erstellt', + '202' => 'Akzeptiert', + '204' => 'Kein Inhalt', + '400' => 'Ungültige Anfrage', + '401' => 'Nicht autorisiert', + '403' => 'Verboten', + '404' => 'Nicht gefunden', + '408' => 'Zeitüberschreitung', + '422' => 'Nicht verarbeitbar', + '429' => 'Zu viele Anfragen', + '500' => 'Interner Serverfehler', + '502' => 'Ungültiges Gateway', + '503' => 'Dienst nicht verfügbar', + '504' => 'Gateway-Zeitüberschreitung', + ], + 'copied' => [ + 'id' => 'Webhook-ID in die Zwischenablage kopiert', + 'secret' => 'Signatur-Secret in die Zwischenablage kopiert', + 'response' => 'Antworttext kopiert', + 'payload' => 'Payload kopiert', + ], + 'errors' => [ + 'endpoint_not_allowed' => 'Dieser Endpoint ist nicht erlaubt.', + 'endpoint_unreachable' => 'Der Endpoint ist nicht erreichbar.', + 'endpoint_http_status' => 'Der Endpoint hat HTTP :status zurückgegeben.', + ], + 'flash' => [ + 'created' => 'Webhook erstellt.', + 'updated' => 'Webhook aktualisiert.', + 'deleted' => 'Webhook gelöscht.', + 'secret_rotated' => 'Signatur-Secret rotiert.', + 'replayed' => 'Webhook-Ereignis erneut gesendet.', + 'tested' => 'Test-Ereignis gesendet.', + ], + 'mail' => [ + 'paused_subject' => 'Webhook pausiert: :endpoint', + 'paused_title' => 'Webhook nach wiederholten Fehlern pausiert', + 'paused_preview' => 'Wir haben einen Webhook nach 5 aufeinanderfolgenden Zustellfehlern pausiert.', + 'paused_body' => 'Wir haben den Webhook unter :endpoint nach 5 aufeinanderfolgenden Zustellfehlern pausiert. Prüfe den Endpoint und aktiviere ihn wieder auf der Webhook-Detailseite.', + 'paused_cta' => 'Webhook anzeigen', + ], +]; diff --git a/lang/el/automations.php b/lang/el/automations.php index da46f16c..f4aa2d8e 100644 --- a/lang/el/automations.php +++ b/lang/el/automations.php @@ -62,7 +62,6 @@ 'delay' => 'Καθυστέρηση', 'condition' => 'Συνθήκη', 'publish' => 'Δημοσίευση', - 'webhook' => 'Webhook', 'end' => 'Τέλος', 'fetch_rss' => 'Ανάκτηση RSS', 'http_request' => 'Αίτημα HTTP', @@ -213,7 +212,6 @@ 'delay' => 'Καθυστέρηση', 'condition' => 'Συνθήκη', 'publish' => 'Δημοσίευση', - 'webhook' => 'Webhook', 'end' => 'Τέλος', 'end_summary' => 'Σταματά τον αυτοματισμό εδώ', 'fetch_rss' => 'Ανάκτηση RSS', @@ -326,11 +324,6 @@ 'scheduled_offset' => 'Μετατόπιση από το έναυσμα (λεπτά)', 'offset_summary' => ':mode · +:offset λεπτά', ], - 'webhook' => [ - 'url' => 'URL', - 'method' => 'Μέθοδος', - 'payload_template' => 'Πρότυπο payload (JSON)', - ], 'end' => [ 'reason' => 'Αιτία (προαιρετικό)', 'reason_placeholder' => 'π.χ. Φιλτραρίστηκε από τη συνθήκη', @@ -394,10 +387,6 @@ 'graph_contains_cycle' => 'Το γράφημα του αυτοματισμού περιέχει κύκλο.', 'only_failed_can_retry' => 'Μόνο οι αποτυχημένες εκτελέσεις μπορούν να επαναληφθούν.', 'no_generated_post' => 'Δεν βρέθηκε δημιουργημένη δημοσίευση στην εκτέλεση.', - 'webhook_server_error' => 'Σφάλμα διακομιστή webhook.', - 'webhook_request_failed' => 'Το αίτημα webhook δεν ήταν δυνατό να ολοκληρωθεί.', - 'webhook_missing_url' => 'Από τον κόμβο webhook λείπει μια διεύθυνση URL.', - 'webhook_invalid_payload_json' => 'Το πρότυπο payload δεν είναι έγκυρο JSON.', 'url_not_allowed' => 'Η διεύθυνση URL του αιτήματος δείχνει σε ιδιωτική ή μη προσβάσιμη διεύθυνση και αποκλείστηκε.', 'node_no_longer_exists' => 'Ο κόμβος :node_id δεν υπάρχει πλέον στον αυτοματισμό.', 'no_trigger_connection' => 'Κανένας κόμβος δεν είναι συνδεδεμένος με τον κόμβο εναύσματος.', diff --git a/lang/el/sidebar.php b/lang/el/sidebar.php index 82164674..ae1facb0 100644 --- a/lang/el/sidebar.php +++ b/lang/el/sidebar.php @@ -16,6 +16,7 @@ 'signatures' => 'Υπογραφές', 'labels' => 'Ετικέτες', 'assets' => 'Στοιχεία', + 'webhooks' => 'Webhooks', 'mcp' => 'MCP', ], 'language' => 'Γλώσσα: :name', diff --git a/lang/el/webhooks.php b/lang/el/webhooks.php new file mode 100644 index 00000000..3df8adf8 --- /dev/null +++ b/lang/el/webhooks.php @@ -0,0 +1,139 @@ + 'Webhooks', + 'description' => 'Λάβετε ειδοποιήσεις σε πραγματικό χρόνο όταν δημιουργούνται, προγραμματίζονται, αποπρογραμματίζονται, δημοσιεύονται ή αποτυγχάνουν αναρτήσεις.', + 'new' => 'Δημιουργία webhook', + 'empty_title' => 'Δεν υπάρχουν ακόμα webhooks', + 'empty_description' => 'Δημιουργήστε ένα webhook για να λαμβάνετε ειδοποιήσεις συμβάντων σε πραγματικό χρόνο.', + 'table' => [ + 'endpoint' => 'Endpoint', + 'events' => 'Ακούει', + 'status' => 'Κατάσταση', + 'last_sent' => 'Τελευταία αποστολή', + ], + 'events_count' => '{1} :count συμβάν|[2,*] :count συμβάντα', + 'never' => 'Ποτέ', + 'status' => [ + 'enabled' => 'Ενεργό', + 'disabled' => 'Ανενεργό', + 'paused' => 'Σε παύση', + ], + 'actions' => [ + 'view' => 'Προβολή λεπτομερειών', + 'copy_id' => 'Αντιγραφή ID webhook', + 'delete' => 'Διαγραφή', + 'edit' => 'Επεξεργασία endpoint', + 'enable' => 'Ενεργοποίηση endpoint', + 'disable' => 'Απενεργοποίηση endpoint', + 'rotate' => 'Αλλαγή μυστικού υπογραφής', + 'send_test' => 'Αποστολή δοκιμαστικού συμβάντος', + 'replay' => 'Επανάληψη αποστολής', + 'reveal_secret' => 'Εμφάνιση μυστικού', + 'hide_secret' => 'Απόκρυψη μυστικού', + 'copy_secret' => 'Αντιγραφή μυστικού', + ], + 'create' => [ + 'title' => 'Δημιουργία webhook', + 'description' => 'Ρυθμίστε ένα endpoint για να λαμβάνετε ειδοποιήσεις webhook.', + 'endpoint' => 'URL του endpoint', + 'endpoint_placeholder' => 'https://example.com/webhooks', + 'events' => 'Συμβάντα', + 'events_placeholder' => 'Επιλογή συμβάντων...', + 'events_selected' => '{1} :count συμβάν επιλεγμένο|[2,*] :count συμβάντα επιλεγμένα', + 'search_events' => 'Αναζήτηση συμβάντων...', + 'no_events' => 'Δεν βρέθηκαν συμβάντα', + 'submit' => 'Δημιουργία webhook', + 'cancel' => 'Ακύρωση', + ], + 'edit' => [ + 'title' => 'Επεξεργασία endpoint', + 'description' => 'Ενημερώστε το URL του endpoint και τα συμβάντα που θα ακούει.', + 'submit' => 'Αποθήκευση αλλαγών', + 'cancel' => 'Ακύρωση', + ], + 'delete' => [ + 'title' => 'Διαγραφή webhook', + 'description' => 'Θέλετε σίγουρα να διαγράψετε αυτό το webhook; Δεν θα λαμβάνετε πλέον ειδοποιήσεις συμβάντων σε αυτό το endpoint.', + 'confirm' => 'Διαγραφή webhook', + 'cancel' => 'Ακύρωση', + ], + 'rotate' => [ + 'title' => 'Αλλαγή μυστικού υπογραφής', + 'description' => 'Θα δημιουργηθεί ένα νέο μυστικό υπογραφής. Το τρέχον μυστικό σταματά να λειτουργεί αμέσως. Ενημερώστε το endpoint σας ώστε να χρησιμοποιεί το νέο μυστικό.', + 'submit' => 'Αλλαγή μυστικού', + 'cancel' => 'Ακύρωση', + ], + 'show' => [ + 'signing_secret' => 'Μυστικό υπογραφής', + 'last_sent' => 'Τελευταία αποστολή :time', + 'listening_for' => 'Ακούει', + 'http_status' => 'Κατάσταση HTTP', + 'status_code' => ':code - :reason', + 'attempts' => 'Προσπάθειες', + 'delivered_at' => 'Παραδόθηκε στις', + 'response_body' => 'Σώμα απάντησης', + 'response' => 'Απάντηση', + 'no_response_body' => 'Χωρίς σώμα απάντησης', + 'no_response' => 'Χωρίς απάντηση', + 'payload' => 'Payload μηνύματος', + 'empty_title' => 'Δεν υπάρχουν ακόμα συμβάντα', + 'empty_description' => 'Όταν δημιουργούνται, προγραμματίζονται, αποπρογραμματίζονται ή δημοσιεύονται αναρτήσεις, τα συμβάντα του webhook εμφανίζονται εδώ.', + ], + 'events' => [ + 'group_posts' => 'Αναρτήσεις', + 'post_created' => 'Η ανάρτηση δημιουργήθηκε', + 'post_scheduled' => 'Η ανάρτηση προγραμματίστηκε', + 'post_unscheduled' => 'Ακυρώθηκε ο προγραμματισμός', + 'post_published' => 'Η ανάρτηση δημοσιεύτηκε', + 'post_partially_published' => 'Μερική δημοσίευση', + 'post_failed' => 'Η ανάρτηση απέτυχε', + 'post_deleted' => 'Η ανάρτηση διαγράφηκε', + ], + 'http_reasons' => [ + 'unknown' => 'Άγνωστο', + '200' => 'OK', + '201' => 'Δημιουργήθηκε', + '202' => 'Αποδεκτό', + '204' => 'Χωρίς περιεχόμενο', + '400' => 'Εσφαλμένο αίτημα', + '401' => 'Μη εξουσιοδοτημένο', + '403' => 'Απαγορευμένο', + '404' => 'Δεν βρέθηκε', + '408' => 'Λήξη χρόνου αιτήματος', + '422' => 'Οντότητα που δεν μπορεί να επεξεργαστεί', + '429' => 'Πάρα πολλά αιτήματα', + '500' => 'Εσωτερικό σφάλμα διακομιστή', + '502' => 'Μη έγκυρη πύλη', + '503' => 'Η υπηρεσία δεν είναι διαθέσιμη', + '504' => 'Λήξη χρόνου πύλης', + ], + 'copied' => [ + 'id' => 'Το ID του webhook αντιγράφηκε στο πρόχειρο', + 'secret' => 'Το μυστικό υπογραφής αντιγράφηκε στο πρόχειρο', + 'response' => 'Το σώμα απάντησης αντιγράφηκε', + 'payload' => 'Το payload αντιγράφηκε', + ], + 'errors' => [ + 'endpoint_not_allowed' => 'Αυτό το endpoint δεν επιτρέπεται.', + 'endpoint_unreachable' => 'Το endpoint δεν είναι προσβάσιμο.', + 'endpoint_http_status' => 'Το endpoint επέστρεψε HTTP :status.', + ], + 'flash' => [ + 'created' => 'Το webhook δημιουργήθηκε.', + 'updated' => 'Το webhook ενημερώθηκε.', + 'deleted' => 'Το webhook διαγράφηκε.', + 'secret_rotated' => 'Το μυστικό υπογραφής άλλαξε.', + 'replayed' => 'Το συμβάν του webhook στάλθηκε ξανά.', + 'tested' => 'Το δοκιμαστικό συμβάν στάλθηκε.', + ], + 'mail' => [ + 'paused_subject' => 'Το webhook τέθηκε σε παύση: :endpoint', + 'paused_title' => 'Το webhook τέθηκε σε παύση μετά από επαναλαμβανόμενες αποτυχίες', + 'paused_preview' => 'Θέσαμε ένα webhook σε παύση μετά από 5 διαδοχικές αποτυχίες παράδοσης.', + 'paused_body' => 'Θέσαμε το webhook στο :endpoint σε παύση μετά από 5 διαδοχικές αποτυχίες παράδοσης. Ελέγξτε το endpoint και ενεργοποιήστε το ξανά από τη σελίδα λεπτομερειών του webhook.', + 'paused_cta' => 'Προβολή webhook', + ], +]; diff --git a/lang/en/automations.php b/lang/en/automations.php index aa10e72b..7ef5001e 100644 --- a/lang/en/automations.php +++ b/lang/en/automations.php @@ -62,7 +62,6 @@ 'delay' => 'Delay', 'condition' => 'Condition', 'publish' => 'Publish', - 'webhook' => 'Webhook', 'end' => 'End', 'fetch_rss' => 'Fetch RSS', 'http_request' => 'HTTP request', @@ -213,7 +212,6 @@ 'delay' => 'Delay', 'condition' => 'Condition', 'publish' => 'Publish', - 'webhook' => 'Webhook', 'end' => 'End', 'end_summary' => 'Stops the automation here', 'fetch_rss' => 'Fetch RSS', @@ -326,11 +324,6 @@ 'scheduled_offset' => 'Offset from trigger (minutes)', 'offset_summary' => ':mode · +:offset min', ], - 'webhook' => [ - 'url' => 'URL', - 'method' => 'Method', - 'payload_template' => 'Payload template (JSON)', - ], 'end' => [ 'reason' => 'Reason (optional)', 'reason_placeholder' => 'e.g. Filtered out by condition', @@ -394,10 +387,6 @@ 'graph_contains_cycle' => 'Automation graph contains a cycle.', 'only_failed_can_retry' => 'Only failed runs can be retried.', 'no_generated_post' => 'No generated post found on run.', - 'webhook_server_error' => 'Webhook server error.', - 'webhook_request_failed' => 'The webhook request could not be completed.', - 'webhook_missing_url' => 'The webhook node is missing a URL.', - 'webhook_invalid_payload_json' => 'The payload template is not valid JSON.', 'url_not_allowed' => 'The request URL points to a private or unreachable address and was blocked.', 'node_no_longer_exists' => 'Node :node_id no longer exists in the automation.', 'no_trigger_connection' => 'No node connected to the Trigger node.', diff --git a/lang/en/sidebar.php b/lang/en/sidebar.php index 2b4f5c4a..60fc9193 100644 --- a/lang/en/sidebar.php +++ b/lang/en/sidebar.php @@ -16,6 +16,7 @@ 'signatures' => 'Signatures', 'labels' => 'Labels', 'assets' => 'Assets', + 'webhooks' => 'Webhooks', 'mcp' => 'MCP', ], 'language' => 'Language: :name', diff --git a/lang/en/webhooks.php b/lang/en/webhooks.php new file mode 100644 index 00000000..a3dce5c6 --- /dev/null +++ b/lang/en/webhooks.php @@ -0,0 +1,139 @@ + 'Webhooks', + 'description' => 'Receive real-time notifications when posts are created, scheduled, unscheduled, published, or fail.', + 'new' => 'Create webhook', + 'empty_title' => 'No webhooks yet', + 'empty_description' => 'Create a webhook to receive real-time event notifications.', + 'table' => [ + 'endpoint' => 'Endpoint', + 'events' => 'Listening for', + 'status' => 'Status', + 'last_sent' => 'Last sent', + ], + 'events_count' => '{1} :count event|[2,*] :count events', + 'never' => 'Never', + 'status' => [ + 'enabled' => 'Enabled', + 'disabled' => 'Disabled', + 'paused' => 'Paused', + ], + 'actions' => [ + 'view' => 'View details', + 'copy_id' => 'Copy webhook ID', + 'delete' => 'Delete', + 'edit' => 'Edit endpoint', + 'enable' => 'Enable endpoint', + 'disable' => 'Disable endpoint', + 'rotate' => 'Rotate signing secret', + 'send_test' => 'Send test event', + 'replay' => 'Replay', + 'reveal_secret' => 'Reveal secret', + 'hide_secret' => 'Hide secret', + 'copy_secret' => 'Copy secret', + ], + 'create' => [ + 'title' => 'Create webhook', + 'description' => 'Configure an endpoint to receive webhook notifications.', + 'endpoint' => 'Endpoint URL', + 'endpoint_placeholder' => 'https://example.com/webhooks', + 'events' => 'Events', + 'events_placeholder' => 'Select events...', + 'events_selected' => '{1} :count event selected|[2,*] :count events selected', + 'search_events' => 'Search events...', + 'no_events' => 'No events found', + 'submit' => 'Create webhook', + 'cancel' => 'Cancel', + ], + 'edit' => [ + 'title' => 'Edit endpoint', + 'description' => 'Update the webhook endpoint URL and events to listen for.', + 'submit' => 'Save changes', + 'cancel' => 'Cancel', + ], + 'delete' => [ + 'title' => 'Delete webhook', + 'description' => 'Are you sure you want to delete this webhook? You will no longer receive event notifications at this endpoint.', + 'confirm' => 'Delete webhook', + 'cancel' => 'Cancel', + ], + 'rotate' => [ + 'title' => 'Rotate signing secret', + 'description' => 'This will generate a new signing secret. The current secret will stop working immediately. Make sure to update your endpoint to use the new secret.', + 'submit' => 'Rotate secret', + 'cancel' => 'Cancel', + ], + 'show' => [ + 'signing_secret' => 'Signing secret', + 'last_sent' => 'Last sent :time', + 'listening_for' => 'Listening for', + 'http_status' => 'HTTP status', + 'status_code' => ':code - :reason', + 'attempts' => 'Attempts', + 'delivered_at' => 'Delivered at', + 'response_body' => 'Response body', + 'response' => 'Response', + 'no_response_body' => 'No response body', + 'no_response' => 'No response', + 'payload' => 'Message payload', + 'empty_title' => 'No webhook events yet', + 'empty_description' => 'Once posts are created, scheduled, unscheduled, or published, you will see the webhook events here.', + ], + 'events' => [ + 'group_posts' => 'Posts', + 'post_created' => 'Post created', + 'post_scheduled' => 'Post scheduled', + 'post_unscheduled' => 'Post unscheduled', + 'post_published' => 'Post published', + 'post_partially_published' => 'Post partially published', + 'post_failed' => 'Post failed', + 'post_deleted' => 'Post deleted', + ], + 'http_reasons' => [ + 'unknown' => 'Unknown', + '200' => 'OK', + '201' => 'Created', + '202' => 'Accepted', + '204' => 'No Content', + '400' => 'Bad Request', + '401' => 'Unauthorized', + '403' => 'Forbidden', + '404' => 'Not Found', + '408' => 'Request Timeout', + '422' => 'Unprocessable Entity', + '429' => 'Too Many Requests', + '500' => 'Internal Server Error', + '502' => 'Bad Gateway', + '503' => 'Service Unavailable', + '504' => 'Gateway Timeout', + ], + 'copied' => [ + 'id' => 'Webhook ID copied to clipboard', + 'secret' => 'Signing secret copied to clipboard', + 'response' => 'Response body copied', + 'payload' => 'Payload copied', + ], + 'errors' => [ + 'endpoint_not_allowed' => 'This endpoint is not allowed.', + 'endpoint_unreachable' => 'The endpoint is not reachable.', + 'endpoint_http_status' => 'The endpoint returned HTTP :status.', + ], + 'flash' => [ + 'created' => 'Webhook created.', + 'updated' => 'Webhook updated.', + 'deleted' => 'Webhook deleted.', + 'secret_rotated' => 'Signing secret rotated.', + 'replayed' => 'Webhook event replayed.', + 'tested' => 'Test event sent.', + ], + 'mail' => [ + 'paused_subject' => 'Webhook paused: :endpoint', + 'paused_title' => 'Webhook paused after repeated failures', + 'paused_preview' => 'We paused a webhook after 5 consecutive delivery failures.', + 'paused_body' => 'We paused the webhook at :endpoint after 5 consecutive delivery failures. Review the endpoint and re-enable it from the webhook details page.', + 'paused_cta' => 'View webhook', + ], +]; diff --git a/lang/es/automations.php b/lang/es/automations.php index b8a05cd3..3d484ae2 100644 --- a/lang/es/automations.php +++ b/lang/es/automations.php @@ -62,7 +62,6 @@ 'delay' => 'Espera', 'condition' => 'Condición', 'publish' => 'Publicar', - 'webhook' => 'Webhook', 'end' => 'Fin', 'fetch_rss' => 'Obtener RSS', 'http_request' => 'Petición HTTP', @@ -213,7 +212,6 @@ 'delay' => 'Retraso', 'condition' => 'Condición', 'publish' => 'Publicar', - 'webhook' => 'Webhook', 'end' => 'Terminar', 'end_summary' => 'Termina la automatización aquí', 'fetch_rss' => 'Obtener RSS', @@ -326,11 +324,6 @@ 'scheduled_offset' => 'Diferencia desde el disparador (minutos)', 'offset_summary' => ':mode · +:offset min', ], - 'webhook' => [ - 'url' => 'URL', - 'method' => 'Método', - 'payload_template' => 'Plantilla de payload (JSON)', - ], 'end' => [ 'reason' => 'Razón (opcional)', 'reason_placeholder' => 'p.ej. Filtrado por la condición', @@ -394,10 +387,6 @@ 'graph_contains_cycle' => 'El grafo de la automatización contiene un ciclo.', 'only_failed_can_retry' => 'Solo se pueden reintentar ejecuciones fallidas.', 'no_generated_post' => 'No se encontró un post generado en la ejecución.', - 'webhook_server_error' => 'Error del servidor del webhook.', - 'webhook_request_failed' => 'No se pudo completar la solicitud del webhook.', - 'webhook_missing_url' => 'Al nodo de webhook le falta la URL.', - 'webhook_invalid_payload_json' => 'La plantilla de payload no es un JSON válido.', 'url_not_allowed' => 'La URL de la petición apunta a una dirección privada o inaccesible y fue bloqueada.', 'node_no_longer_exists' => 'El nodo :node_id ya no existe en la automatización.', 'no_trigger_connection' => 'Ningún nodo está conectado al nodo disparador.', diff --git a/lang/es/sidebar.php b/lang/es/sidebar.php index aeb9b33d..10624163 100644 --- a/lang/es/sidebar.php +++ b/lang/es/sidebar.php @@ -16,6 +16,7 @@ 'signatures' => 'Firmas', 'labels' => 'Etiquetas', 'assets' => 'Medios', + 'webhooks' => 'Webhooks', 'mcp' => 'MCP', ], 'language' => 'Idioma: :name', diff --git a/lang/es/webhooks.php b/lang/es/webhooks.php new file mode 100644 index 00000000..dff8a47a --- /dev/null +++ b/lang/es/webhooks.php @@ -0,0 +1,139 @@ + 'Webhooks', + 'description' => 'Recibe notificaciones en tiempo real cuando se crean, programan, desprograman, publican o fallan los posts.', + 'new' => 'Crear webhook', + 'empty_title' => 'Aún no hay webhooks', + 'empty_description' => 'Crea un webhook para recibir notificaciones de eventos en tiempo real.', + 'table' => [ + 'endpoint' => 'Endpoint', + 'events' => 'Escuchando', + 'status' => 'Estado', + 'last_sent' => 'Último envío', + ], + 'events_count' => '{1} :count evento|[2,*] :count eventos', + 'never' => 'Nunca', + 'status' => [ + 'enabled' => 'Activo', + 'disabled' => 'Desactivado', + 'paused' => 'Pausado', + ], + 'actions' => [ + 'view' => 'Ver detalles', + 'copy_id' => 'Copiar ID del webhook', + 'delete' => 'Eliminar', + 'edit' => 'Editar endpoint', + 'enable' => 'Activar endpoint', + 'disable' => 'Desactivar endpoint', + 'rotate' => 'Rotar secret de firma', + 'send_test' => 'Enviar evento de prueba', + 'replay' => 'Reenviar', + 'reveal_secret' => 'Mostrar secret', + 'hide_secret' => 'Ocultar secret', + 'copy_secret' => 'Copiar secret', + ], + 'create' => [ + 'title' => 'Crear webhook', + 'description' => 'Configura un endpoint para recibir notificaciones de webhook.', + 'endpoint' => 'URL del endpoint', + 'endpoint_placeholder' => 'https://example.com/webhooks', + 'events' => 'Eventos', + 'events_placeholder' => 'Seleccionar eventos...', + 'events_selected' => '{1} :count evento seleccionado|[2,*] :count eventos seleccionados', + 'search_events' => 'Buscar eventos...', + 'no_events' => 'No se encontraron eventos', + 'submit' => 'Crear webhook', + 'cancel' => 'Cancelar', + ], + 'edit' => [ + 'title' => 'Editar endpoint', + 'description' => 'Actualiza la URL del endpoint y los eventos a escuchar.', + 'submit' => 'Guardar cambios', + 'cancel' => 'Cancelar', + ], + 'delete' => [ + 'title' => 'Eliminar webhook', + 'description' => '¿Seguro que quieres eliminar este webhook? Ya no recibirás notificaciones de eventos en este endpoint.', + 'confirm' => 'Eliminar webhook', + 'cancel' => 'Cancelar', + ], + 'rotate' => [ + 'title' => 'Rotar secret de firma', + 'description' => 'Esto genera un nuevo secret de firma. El secret actual deja de funcionar de inmediato. Actualiza tu endpoint para usar el nuevo secret.', + 'submit' => 'Rotar secret', + 'cancel' => 'Cancelar', + ], + 'show' => [ + 'signing_secret' => 'Secret de firma', + 'last_sent' => 'Último envío :time', + 'listening_for' => 'Escuchando', + 'http_status' => 'Estado HTTP', + 'status_code' => ':code - :reason', + 'attempts' => 'Intentos', + 'delivered_at' => 'Entregado el', + 'response_body' => 'Cuerpo de la respuesta', + 'response' => 'Respuesta', + 'no_response_body' => 'Sin cuerpo de respuesta', + 'no_response' => 'Sin respuesta', + 'payload' => 'Payload del mensaje', + 'empty_title' => 'Aún no hay eventos', + 'empty_description' => 'Cuando se creen, programen, desprogramen o publiquen posts, verás aquí los eventos del webhook.', + ], + 'events' => [ + 'group_posts' => 'Posts', + 'post_created' => 'Post creado', + 'post_scheduled' => 'Post programado', + 'post_unscheduled' => 'Post desprogramado', + 'post_published' => 'Post publicado', + 'post_partially_published' => 'Post parcialmente publicado', + 'post_failed' => 'Post fallido', + 'post_deleted' => 'Post eliminado', + ], + 'http_reasons' => [ + 'unknown' => 'Desconocido', + '200' => 'OK', + '201' => 'Creado', + '202' => 'Aceptado', + '204' => 'Sin contenido', + '400' => 'Solicitud incorrecta', + '401' => 'No autorizado', + '403' => 'Prohibido', + '404' => 'No encontrado', + '408' => 'Tiempo de espera agotado', + '422' => 'Entidad no procesable', + '429' => 'Demasiadas solicitudes', + '500' => 'Error interno del servidor', + '502' => 'Puerta de enlace no válida', + '503' => 'Servicio no disponible', + '504' => 'Tiempo de espera de la puerta de enlace', + ], + 'copied' => [ + 'id' => 'ID del webhook copiado al portapapeles', + 'secret' => 'Secret de firma copiado al portapapeles', + 'response' => 'Cuerpo de la respuesta copiado', + 'payload' => 'Payload copiado', + ], + 'errors' => [ + 'endpoint_not_allowed' => 'Este endpoint no está permitido.', + 'endpoint_unreachable' => 'El endpoint no es accesible.', + 'endpoint_http_status' => 'El endpoint devolvió HTTP :status.', + ], + 'flash' => [ + 'created' => 'Webhook creado.', + 'updated' => 'Webhook actualizado.', + 'deleted' => 'Webhook eliminado.', + 'secret_rotated' => 'Secret de firma rotado.', + 'replayed' => 'Evento del webhook reenviado.', + 'tested' => 'Evento de prueba enviado.', + ], + 'mail' => [ + 'paused_subject' => 'Webhook pausado: :endpoint', + 'paused_title' => 'Webhook pausado tras fallos repetidos', + 'paused_preview' => 'Pausamos un webhook tras 5 fallos consecutivos de entrega.', + 'paused_body' => 'Pausamos el webhook en :endpoint tras 5 fallos consecutivos de entrega. Revisa el endpoint y actívalo de nuevo en la página de detalles del webhook.', + 'paused_cta' => 'Ver webhook', + ], +]; diff --git a/lang/fr/automations.php b/lang/fr/automations.php index 28e92a23..f57341d4 100644 --- a/lang/fr/automations.php +++ b/lang/fr/automations.php @@ -62,7 +62,6 @@ 'delay' => 'Délai', 'condition' => 'Condition', 'publish' => 'Publier', - 'webhook' => 'Webhook', 'end' => 'Fin', 'fetch_rss' => 'Récupérer RSS', 'http_request' => 'Requête HTTP', @@ -213,7 +212,6 @@ 'delay' => 'Délai', 'condition' => 'Condition', 'publish' => 'Publier', - 'webhook' => 'Webhook', 'end' => 'Fin', 'end_summary' => 'Arrête l\'automatisation ici', 'fetch_rss' => 'Récupérer RSS', @@ -326,11 +324,6 @@ 'scheduled_offset' => 'Décalage par rapport au déclencheur (minutes)', 'offset_summary' => ':mode · +:offset min', ], - 'webhook' => [ - 'url' => 'URL', - 'method' => 'Méthode', - 'payload_template' => 'Modèle de payload (JSON)', - ], 'end' => [ 'reason' => 'Raison (facultatif)', 'reason_placeholder' => 'par ex. Filtré par la condition', @@ -394,10 +387,6 @@ 'graph_contains_cycle' => 'Le graphe de l\'automatisation contient un cycle.', 'only_failed_can_retry' => 'Seules les exécutions échouées peuvent être relancées.', 'no_generated_post' => 'Aucune publication générée trouvée pour cette exécution.', - 'webhook_server_error' => 'Erreur du serveur webhook.', - 'webhook_request_failed' => 'La requête webhook n\'a pas pu être exécutée.', - 'webhook_missing_url' => 'Il manque une URL au nœud webhook.', - 'webhook_invalid_payload_json' => 'Le modèle de payload n\'est pas du JSON valide.', 'url_not_allowed' => 'L\'URL de la requête pointe vers une adresse privée ou inaccessible et a été bloquée.', 'node_no_longer_exists' => 'Le nœud :node_id n\'existe plus dans l\'automatisation.', 'no_trigger_connection' => 'Aucun nœud connecté au nœud déclencheur.', diff --git a/lang/fr/sidebar.php b/lang/fr/sidebar.php index 14208543..2933a9d9 100644 --- a/lang/fr/sidebar.php +++ b/lang/fr/sidebar.php @@ -16,6 +16,7 @@ 'signatures' => 'Signatures', 'labels' => 'Étiquettes', 'assets' => 'Médias', + 'webhooks' => 'Webhooks', 'mcp' => 'MCP', ], 'language' => 'Langue : :name', diff --git a/lang/fr/webhooks.php b/lang/fr/webhooks.php new file mode 100644 index 00000000..4748969a --- /dev/null +++ b/lang/fr/webhooks.php @@ -0,0 +1,139 @@ + 'Webhooks', + 'description' => 'Recevez des notifications en temps réel lorsque des publications sont créées, programmées, déprogrammées, publiées ou échouent.', + 'new' => 'Créer un webhook', + 'empty_title' => 'Aucun webhook pour le moment', + 'empty_description' => 'Créez un webhook pour recevoir des notifications d\'événements en temps réel.', + 'table' => [ + 'endpoint' => 'Endpoint', + 'events' => 'Écoute', + 'status' => 'Statut', + 'last_sent' => 'Dernier envoi', + ], + 'events_count' => '{1} :count événement|[2,*] :count événements', + 'never' => 'Jamais', + 'status' => [ + 'enabled' => 'Actif', + 'disabled' => 'Désactivé', + 'paused' => 'En pause', + ], + 'actions' => [ + 'view' => 'Voir les détails', + 'copy_id' => 'Copier l\'ID du webhook', + 'delete' => 'Supprimer', + 'edit' => 'Modifier l\'endpoint', + 'enable' => 'Activer l\'endpoint', + 'disable' => 'Désactiver l\'endpoint', + 'rotate' => 'Faire tourner le secret de signature', + 'send_test' => 'Envoyer un événement de test', + 'replay' => 'Renvoyer', + 'reveal_secret' => 'Afficher le secret', + 'hide_secret' => 'Masquer le secret', + 'copy_secret' => 'Copier le secret', + ], + 'create' => [ + 'title' => 'Créer un webhook', + 'description' => 'Configurez un endpoint pour recevoir les notifications de webhook.', + 'endpoint' => 'URL de l\'endpoint', + 'endpoint_placeholder' => 'https://example.com/webhooks', + 'events' => 'Événements', + 'events_placeholder' => 'Sélectionner des événements...', + 'events_selected' => '{1} :count événement sélectionné|[2,*] :count événements sélectionnés', + 'search_events' => 'Rechercher des événements...', + 'no_events' => 'Aucun événement trouvé', + 'submit' => 'Créer un webhook', + 'cancel' => 'Annuler', + ], + 'edit' => [ + 'title' => 'Modifier l\'endpoint', + 'description' => 'Mettez à jour l\'URL de l\'endpoint et les événements à écouter.', + 'submit' => 'Enregistrer les modifications', + 'cancel' => 'Annuler', + ], + 'delete' => [ + 'title' => 'Supprimer le webhook', + 'description' => 'Voulez-vous vraiment supprimer ce webhook ? Vous ne recevrez plus de notifications d\'événements sur cet endpoint.', + 'confirm' => 'Supprimer le webhook', + 'cancel' => 'Annuler', + ], + 'rotate' => [ + 'title' => 'Faire tourner le secret de signature', + 'description' => 'Cela génère un nouveau secret de signature. Le secret actuel cesse de fonctionner immédiatement. Mettez à jour votre endpoint pour utiliser le nouveau secret.', + 'submit' => 'Faire tourner le secret', + 'cancel' => 'Annuler', + ], + 'show' => [ + 'signing_secret' => 'Secret de signature', + 'last_sent' => 'Dernier envoi :time', + 'listening_for' => 'Écoute', + 'http_status' => 'Statut HTTP', + 'status_code' => ':code - :reason', + 'attempts' => 'Tentatives', + 'delivered_at' => 'Livré le', + 'response_body' => 'Corps de la réponse', + 'response' => 'Réponse', + 'no_response_body' => 'Aucun corps de réponse', + 'no_response' => 'Aucune réponse', + 'payload' => 'Payload du message', + 'empty_title' => 'Aucun événement pour le moment', + 'empty_description' => 'Lorsque des publications sont créées, programmées, déprogrammées ou publiées, les événements du webhook apparaissent ici.', + ], + 'events' => [ + 'group_posts' => 'Publications', + 'post_created' => 'Publication créée', + 'post_scheduled' => 'Publication programmée', + 'post_unscheduled' => 'Publication déprogrammée', + 'post_published' => 'Publication publiée', + 'post_partially_published' => 'Publication partiellement publiée', + 'post_failed' => 'Échec de publication', + 'post_deleted' => 'Publication supprimée', + ], + 'http_reasons' => [ + 'unknown' => 'Inconnu', + '200' => 'OK', + '201' => 'Créé', + '202' => 'Accepté', + '204' => 'Aucun contenu', + '400' => 'Requête incorrecte', + '401' => 'Non autorisé', + '403' => 'Interdit', + '404' => 'Introuvable', + '408' => 'Délai d\'attente dépassé', + '422' => 'Entité non traitable', + '429' => 'Trop de requêtes', + '500' => 'Erreur interne du serveur', + '502' => 'Passerelle invalide', + '503' => 'Service indisponible', + '504' => 'Délai d\'attente de la passerelle', + ], + 'copied' => [ + 'id' => 'ID du webhook copié dans le presse-papiers', + 'secret' => 'Secret de signature copié dans le presse-papiers', + 'response' => 'Corps de la réponse copié', + 'payload' => 'Payload copié', + ], + 'errors' => [ + 'endpoint_not_allowed' => 'Cet endpoint n\'est pas autorisé.', + 'endpoint_unreachable' => 'L\'endpoint n\'est pas joignable.', + 'endpoint_http_status' => 'L\'endpoint a renvoyé HTTP :status.', + ], + 'flash' => [ + 'created' => 'Webhook créé.', + 'updated' => 'Webhook mis à jour.', + 'deleted' => 'Webhook supprimé.', + 'secret_rotated' => 'Secret de signature renouvelé.', + 'replayed' => 'Événement du webhook renvoyé.', + 'tested' => 'Événement de test envoyé.', + ], + 'mail' => [ + 'paused_subject' => 'Webhook en pause : :endpoint', + 'paused_title' => 'Webhook mis en pause après des échecs répétés', + 'paused_preview' => 'Nous avons mis un webhook en pause après 5 échecs de livraison consécutifs.', + 'paused_body' => 'Nous avons mis le webhook de :endpoint en pause après 5 échecs de livraison consécutifs. Vérifiez l\'endpoint et réactivez-le depuis la page de détails du webhook.', + 'paused_cta' => 'Voir le webhook', + ], +]; diff --git a/lang/it/automations.php b/lang/it/automations.php index 2d01db01..12fc87d2 100644 --- a/lang/it/automations.php +++ b/lang/it/automations.php @@ -62,7 +62,6 @@ 'delay' => 'Ritardo', 'condition' => 'Condizione', 'publish' => 'Pubblica', - 'webhook' => 'Webhook', 'end' => 'Fine', 'fetch_rss' => 'Recupera RSS', 'http_request' => 'Richiesta HTTP', @@ -213,7 +212,6 @@ 'delay' => 'Ritardo', 'condition' => 'Condizione', 'publish' => 'Pubblica', - 'webhook' => 'Webhook', 'end' => 'Fine', 'end_summary' => 'Interrompe l\'automazione qui', 'fetch_rss' => 'Recupera RSS', @@ -326,11 +324,6 @@ 'scheduled_offset' => 'Scostamento dal trigger (minuti)', 'offset_summary' => ':mode · +:offset min', ], - 'webhook' => [ - 'url' => 'URL', - 'method' => 'Metodo', - 'payload_template' => 'Modello di payload (JSON)', - ], 'end' => [ 'reason' => 'Motivo (facoltativo)', 'reason_placeholder' => 'es. Escluso dalla condizione', @@ -394,10 +387,6 @@ 'graph_contains_cycle' => 'Il grafo dell\'automazione contiene un ciclo.', 'only_failed_can_retry' => 'Solo le esecuzioni non riuscite possono essere ritentate.', 'no_generated_post' => 'Nessun post generato trovato nell\'esecuzione.', - 'webhook_server_error' => 'Errore del server webhook.', - 'webhook_request_failed' => 'Impossibile completare la richiesta webhook.', - 'webhook_missing_url' => 'Al nodo webhook manca un URL.', - 'webhook_invalid_payload_json' => 'Il modello di payload non è un JSON valido.', 'url_not_allowed' => 'L\'URL della richiesta punta a un indirizzo privato o irraggiungibile ed è stato bloccato.', 'node_no_longer_exists' => 'Il nodo :node_id non esiste più nell\'automazione.', 'no_trigger_connection' => 'Nessun nodo collegato al nodo Trigger.', diff --git a/lang/it/sidebar.php b/lang/it/sidebar.php index 61a15fc6..8d0de920 100644 --- a/lang/it/sidebar.php +++ b/lang/it/sidebar.php @@ -16,6 +16,7 @@ 'signatures' => 'Firme', 'labels' => 'Etichette', 'assets' => 'Risorse', + 'webhooks' => 'Webhook', 'mcp' => 'MCP', ], 'language' => 'Lingua: :name', diff --git a/lang/it/webhooks.php b/lang/it/webhooks.php new file mode 100644 index 00000000..62a995d3 --- /dev/null +++ b/lang/it/webhooks.php @@ -0,0 +1,139 @@ + 'Webhooks', + 'description' => 'Ricevi notifiche in tempo reale quando i post vengono creati, programmati, deprogrammati, pubblicati o falliscono.', + 'new' => 'Crea webhook', + 'empty_title' => 'Nessun webhook ancora', + 'empty_description' => 'Crea un webhook per ricevere notifiche di eventi in tempo reale.', + 'table' => [ + 'endpoint' => 'Endpoint', + 'events' => 'In ascolto', + 'status' => 'Stato', + 'last_sent' => 'Ultimo invio', + ], + 'events_count' => '{1} :count evento|[2,*] :count eventi', + 'never' => 'Mai', + 'status' => [ + 'enabled' => 'Attivo', + 'disabled' => 'Disattivato', + 'paused' => 'In pausa', + ], + 'actions' => [ + 'view' => 'Vedi dettagli', + 'copy_id' => 'Copia ID del webhook', + 'delete' => 'Elimina', + 'edit' => 'Modifica endpoint', + 'enable' => 'Attiva endpoint', + 'disable' => 'Disattiva endpoint', + 'rotate' => 'Ruota secret di firma', + 'send_test' => 'Invia evento di test', + 'replay' => 'Reinvia', + 'reveal_secret' => 'Mostra secret', + 'hide_secret' => 'Nascondi secret', + 'copy_secret' => 'Copia secret', + ], + 'create' => [ + 'title' => 'Crea webhook', + 'description' => 'Configura un endpoint per ricevere le notifiche webhook.', + 'endpoint' => 'URL dell\'endpoint', + 'endpoint_placeholder' => 'https://example.com/webhooks', + 'events' => 'Eventi', + 'events_placeholder' => 'Seleziona eventi...', + 'events_selected' => '{1} :count evento selezionato|[2,*] :count eventi selezionati', + 'search_events' => 'Cerca eventi...', + 'no_events' => 'Nessun evento trovato', + 'submit' => 'Crea webhook', + 'cancel' => 'Annulla', + ], + 'edit' => [ + 'title' => 'Modifica endpoint', + 'description' => 'Aggiorna l\'URL dell\'endpoint e gli eventi da ascoltare.', + 'submit' => 'Salva modifiche', + 'cancel' => 'Annulla', + ], + 'delete' => [ + 'title' => 'Elimina webhook', + 'description' => 'Vuoi davvero eliminare questo webhook? Non riceverai più notifiche di eventi su questo endpoint.', + 'confirm' => 'Elimina webhook', + 'cancel' => 'Annulla', + ], + 'rotate' => [ + 'title' => 'Ruota secret di firma', + 'description' => 'Questo genera un nuovo secret di firma. Il secret attuale smette di funzionare subito. Aggiorna il tuo endpoint per usare il nuovo secret.', + 'submit' => 'Ruota secret', + 'cancel' => 'Annulla', + ], + 'show' => [ + 'signing_secret' => 'Secret di firma', + 'last_sent' => 'Ultimo invio :time', + 'listening_for' => 'In ascolto', + 'http_status' => 'Stato HTTP', + 'status_code' => ':code - :reason', + 'attempts' => 'Tentativi', + 'delivered_at' => 'Consegnato il', + 'response_body' => 'Corpo della risposta', + 'response' => 'Risposta', + 'no_response_body' => 'Nessun corpo della risposta', + 'no_response' => 'Nessuna risposta', + 'payload' => 'Payload del messaggio', + 'empty_title' => 'Nessun evento ancora', + 'empty_description' => 'Quando i post vengono creati, programmati, deprogrammati o pubblicati, gli eventi del webhook appaiono qui.', + ], + 'events' => [ + 'group_posts' => 'Post', + 'post_created' => 'Post creato', + 'post_scheduled' => 'Post programmato', + 'post_unscheduled' => 'Programmazione annullata', + 'post_published' => 'Post pubblicato', + 'post_partially_published' => 'Post pubblicato parzialmente', + 'post_failed' => 'Post non riuscito', + 'post_deleted' => 'Post eliminato', + ], + 'http_reasons' => [ + 'unknown' => 'Sconosciuto', + '200' => 'OK', + '201' => 'Creato', + '202' => 'Accettato', + '204' => 'Nessun contenuto', + '400' => 'Richiesta non valida', + '401' => 'Non autorizzato', + '403' => 'Vietato', + '404' => 'Non trovato', + '408' => 'Tempo scaduto', + '422' => 'Entità non elaborabile', + '429' => 'Troppe richieste', + '500' => 'Errore interno del server', + '502' => 'Gateway non valido', + '503' => 'Servizio non disponibile', + '504' => 'Tempo scaduto del gateway', + ], + 'copied' => [ + 'id' => 'ID del webhook copiato negli appunti', + 'secret' => 'Secret di firma copiato negli appunti', + 'response' => 'Corpo della risposta copiato', + 'payload' => 'Payload copiato', + ], + 'errors' => [ + 'endpoint_not_allowed' => 'Questo endpoint non è consentito.', + 'endpoint_unreachable' => 'L\'endpoint non è raggiungibile.', + 'endpoint_http_status' => 'L\'endpoint ha restituito HTTP :status.', + ], + 'flash' => [ + 'created' => 'Webhook creato.', + 'updated' => 'Webhook aggiornato.', + 'deleted' => 'Webhook eliminato.', + 'secret_rotated' => 'Secret di firma ruotato.', + 'replayed' => 'Evento del webhook reinviato.', + 'tested' => 'Evento di test inviato.', + ], + 'mail' => [ + 'paused_subject' => 'Webhook in pausa: :endpoint', + 'paused_title' => 'Webhook messo in pausa dopo errori ripetuti', + 'paused_preview' => 'Abbiamo messo in pausa un webhook dopo 5 errori di consegna consecutivi.', + 'paused_body' => 'Abbiamo messo in pausa il webhook su :endpoint dopo 5 errori di consegna consecutivi. Controlla l\'endpoint e riattivalo dalla pagina dei dettagli del webhook.', + 'paused_cta' => 'Vedi webhook', + ], +]; diff --git a/lang/ja/automations.php b/lang/ja/automations.php index 0fd2f6ce..003d4807 100644 --- a/lang/ja/automations.php +++ b/lang/ja/automations.php @@ -62,7 +62,6 @@ 'delay' => '遅延', 'condition' => '条件', 'publish' => '公開', - 'webhook' => 'Webhook', 'end' => '終了', 'fetch_rss' => 'RSS を取得', 'http_request' => 'HTTP リクエスト', @@ -213,7 +212,6 @@ 'delay' => '遅延', 'condition' => '条件', 'publish' => '公開', - 'webhook' => 'Webhook', 'end' => '終了', 'end_summary' => 'ここでオートメーションを停止します', 'fetch_rss' => 'RSS を取得', @@ -326,11 +324,6 @@ 'scheduled_offset' => 'トリガーからのオフセット(分)', 'offset_summary' => ':mode · +:offset 分', ], - 'webhook' => [ - 'url' => 'URL', - 'method' => 'メソッド', - 'payload_template' => 'ペイロードテンプレート(JSON)', - ], 'end' => [ 'reason' => '理由(任意)', 'reason_placeholder' => '例: 条件により除外', @@ -394,10 +387,6 @@ 'graph_contains_cycle' => 'オートメーションのグラフに循環が含まれています。', 'only_failed_can_retry' => '失敗した実行のみ再試行できます。', 'no_generated_post' => '実行で生成された投稿が見つかりません。', - 'webhook_server_error' => 'Webhook サーバーエラー。', - 'webhook_request_failed' => 'Webhook リクエストを完了できませんでした。', - 'webhook_missing_url' => 'Webhook ノードに URL がありません。', - 'webhook_invalid_payload_json' => 'ペイロードテンプレートが有効な JSON ではありません。', 'url_not_allowed' => 'リクエスト URL がプライベートまたは到達不能なアドレスを指しているためブロックされました。', 'node_no_longer_exists' => 'ノード :node_id はオートメーションに存在しなくなりました。', 'no_trigger_connection' => 'トリガーノードに接続されたノードがありません。', diff --git a/lang/ja/sidebar.php b/lang/ja/sidebar.php index 8f5febe5..4e406298 100644 --- a/lang/ja/sidebar.php +++ b/lang/ja/sidebar.php @@ -16,6 +16,7 @@ 'signatures' => '署名', 'labels' => 'ラベル', 'assets' => 'アセット', + 'webhooks' => 'ウェブフック', 'mcp' => 'MCP', ], 'language' => '言語: :name', diff --git a/lang/ja/webhooks.php b/lang/ja/webhooks.php new file mode 100644 index 00000000..d882ba53 --- /dev/null +++ b/lang/ja/webhooks.php @@ -0,0 +1,139 @@ + 'Webhooks', + 'description' => '投稿の作成、予約、予約解除、公開、失敗をリアルタイムで通知します。', + 'new' => 'Webhookを作成', + 'empty_title' => 'Webhookはまだありません', + 'empty_description' => 'Webhookを作成すると、イベント通知をリアルタイムで受け取れます。', + 'table' => [ + 'endpoint' => 'Endpoint', + 'events' => '購読中', + 'status' => 'ステータス', + 'last_sent' => '最終送信', + ], + 'events_count' => '{1} :count件のイベント|[2,*] :count件のイベント', + 'never' => 'なし', + 'status' => [ + 'enabled' => '有効', + 'disabled' => '無効', + 'paused' => '一時停止中', + ], + 'actions' => [ + 'view' => '詳細を見る', + 'copy_id' => 'Webhook IDをコピー', + 'delete' => '削除', + 'edit' => 'Endpointを編集', + 'enable' => 'Endpointを有効化', + 'disable' => 'Endpointを無効化', + 'rotate' => '署名シークレットを更新', + 'send_test' => 'テストイベントを送信', + 'replay' => '再送信', + 'reveal_secret' => 'シークレットを表示', + 'hide_secret' => 'シークレットを隠す', + 'copy_secret' => 'シークレットをコピー', + ], + 'create' => [ + 'title' => 'Webhookを作成', + 'description' => '通知を受け取るEndpointを設定します。', + 'endpoint' => 'Endpoint URL', + 'endpoint_placeholder' => 'https://example.com/webhooks', + 'events' => 'イベント', + 'events_placeholder' => 'イベントを選択...', + 'events_selected' => '{1} :count件のイベントを選択|[2,*] :count件のイベントを選択', + 'search_events' => 'イベントを検索...', + 'no_events' => 'イベントが見つかりません', + 'submit' => 'Webhookを作成', + 'cancel' => 'キャンセル', + ], + 'edit' => [ + 'title' => 'Endpointを編集', + 'description' => 'Endpoint URLと購読するイベントを更新します。', + 'submit' => '変更を保存', + 'cancel' => 'キャンセル', + ], + 'delete' => [ + 'title' => 'Webhookを削除', + 'description' => 'このWebhookを削除しますか?このEndpointへのイベント通知は届かなくなります。', + 'confirm' => 'Webhookを削除', + 'cancel' => 'キャンセル', + ], + 'rotate' => [ + 'title' => '署名シークレットを更新', + 'description' => '新しい署名シークレットを発行します。現在のシークレットはすぐに使えなくなります。Endpoint側も新しいシークレットに更新してください。', + 'submit' => 'シークレットを更新', + 'cancel' => 'キャンセル', + ], + 'show' => [ + 'signing_secret' => '署名シークレット', + 'last_sent' => '最終送信 :time', + 'listening_for' => '購読中', + 'http_status' => 'HTTPステータス', + 'status_code' => ':code - :reason', + 'attempts' => '試行回数', + 'delivered_at' => '配信日時', + 'response_body' => 'レスポンス本文', + 'response' => 'レスポンス', + 'no_response_body' => 'レスポンス本文なし', + 'no_response' => 'レスポンスなし', + 'payload' => 'メッセージのペイロード', + 'empty_title' => 'イベントはまだありません', + 'empty_description' => '投稿が作成、予約、予約解除、公開されると、Webhookイベントがここに表示されます。', + ], + 'events' => [ + 'group_posts' => '投稿', + 'post_created' => '投稿を作成', + 'post_scheduled' => '投稿を予約', + 'post_unscheduled' => '予約を解除', + 'post_published' => '投稿を公開', + 'post_partially_published' => '一部公開', + 'post_failed' => '投稿に失敗', + 'post_deleted' => '投稿を削除', + ], + 'http_reasons' => [ + 'unknown' => '不明', + '200' => 'OK', + '201' => '作成', + '202' => '受理', + '204' => 'コンテンツなし', + '400' => '不正なリクエスト', + '401' => '認証が必要', + '403' => '禁止', + '404' => '見つかりません', + '408' => 'リクエストタイムアウト', + '422' => '処理できないエンティティ', + '429' => 'リクエスト過多', + '500' => 'サーバー内部エラー', + '502' => '不正なゲートウェイ', + '503' => 'サービス利用不可', + '504' => 'ゲートウェイタイムアウト', + ], + 'copied' => [ + 'id' => 'Webhook IDをクリップボードにコピーしました', + 'secret' => '署名シークレットをクリップボードにコピーしました', + 'response' => 'レスポンス本文をコピーしました', + 'payload' => 'ペイロードをコピーしました', + ], + 'errors' => [ + 'endpoint_not_allowed' => 'このEndpointは許可されていません。', + 'endpoint_unreachable' => 'Endpointに到達できません。', + 'endpoint_http_status' => 'EndpointがHTTP :statusを返しました。', + ], + 'flash' => [ + 'created' => 'Webhookを作成しました。', + 'updated' => 'Webhookを更新しました。', + 'deleted' => 'Webhookを削除しました。', + 'secret_rotated' => '署名シークレットを更新しました。', + 'replayed' => 'Webhookイベントを再送信しました。', + 'tested' => 'テストイベントを送信しました。', + ], + 'mail' => [ + 'paused_subject' => 'Webhookを一時停止しました: :endpoint', + 'paused_title' => '連続した失敗のためWebhookを一時停止しました', + 'paused_preview' => '配信が5回連続で失敗したため、Webhookを一時停止しました。', + 'paused_body' => ':endpoint のWebhookを、配信が5回連続で失敗したため一時停止しました。Endpointを確認し、Webhookの詳細ページから再度有効にしてください。', + 'paused_cta' => 'Webhookを見る', + ], +]; diff --git a/lang/ko/automations.php b/lang/ko/automations.php index 420b941f..426839c7 100644 --- a/lang/ko/automations.php +++ b/lang/ko/automations.php @@ -62,7 +62,6 @@ 'delay' => '지연', 'condition' => '조건', 'publish' => '게시', - 'webhook' => '웹훅', 'end' => '종료', 'fetch_rss' => 'RSS 가져오기', 'http_request' => 'HTTP 요청', @@ -213,7 +212,6 @@ 'delay' => '지연', 'condition' => '조건', 'publish' => '게시', - 'webhook' => '웹훅', 'end' => '종료', 'end_summary' => '여기서 자동화를 중지합니다', 'fetch_rss' => 'RSS 가져오기', @@ -326,11 +324,6 @@ 'scheduled_offset' => '트리거 기준 오프셋 (분)', 'offset_summary' => ':mode · +:offset분', ], - 'webhook' => [ - 'url' => 'URL', - 'method' => '메서드', - 'payload_template' => '페이로드 템플릿 (JSON)', - ], 'end' => [ 'reason' => '사유 (선택)', 'reason_placeholder' => '예: 조건에 의해 필터링됨', @@ -394,10 +387,6 @@ 'graph_contains_cycle' => '자동화 그래프에 순환이 포함되어 있습니다.', 'only_failed_can_retry' => '실패한 실행만 재시도할 수 있습니다.', 'no_generated_post' => '실행에서 생성된 게시물을 찾을 수 없습니다.', - 'webhook_server_error' => '웹훅 서버 오류.', - 'webhook_request_failed' => '웹훅 요청을 완료할 수 없습니다.', - 'webhook_missing_url' => '웹훅 노드에 URL이 없습니다.', - 'webhook_invalid_payload_json' => '페이로드 템플릿이 유효한 JSON이 아닙니다.', 'url_not_allowed' => '요청 URL이 비공개이거나 접근할 수 없는 주소를 가리켜 차단되었습니다.', 'node_no_longer_exists' => '노드 :node_id이(가) 자동화에 더 이상 존재하지 않습니다.', 'no_trigger_connection' => '트리거 노드에 연결된 노드가 없습니다.', diff --git a/lang/ko/sidebar.php b/lang/ko/sidebar.php index ddc3c309..a54a72b1 100644 --- a/lang/ko/sidebar.php +++ b/lang/ko/sidebar.php @@ -16,6 +16,7 @@ 'signatures' => '서명', 'labels' => '라벨', 'assets' => '에셋', + 'webhooks' => '웹훅', 'mcp' => 'MCP', ], 'language' => '언어: :name', diff --git a/lang/ko/webhooks.php b/lang/ko/webhooks.php new file mode 100644 index 00000000..8b94a392 --- /dev/null +++ b/lang/ko/webhooks.php @@ -0,0 +1,139 @@ + 'Webhooks', + 'description' => '게시물이 생성되거나, 예약되거나, 예약이 취소되거나, 게시되거나, 실패하면 실시간으로 알림을 받습니다.', + 'new' => '웹훅 만들기', + 'empty_title' => '아직 웹훅이 없습니다', + 'empty_description' => '웹훅을 만들어 이벤트 알림을 실시간으로 받으세요.', + 'table' => [ + 'endpoint' => 'Endpoint', + 'events' => '수신 중', + 'status' => '상태', + 'last_sent' => '마지막 전송', + ], + 'events_count' => '{1} 이벤트 :count개|[2,*] 이벤트 :count개', + 'never' => '없음', + 'status' => [ + 'enabled' => '사용 중', + 'disabled' => '사용 안 함', + 'paused' => '일시정지됨', + ], + 'actions' => [ + 'view' => '세부정보 보기', + 'copy_id' => '웹훅 ID 복사', + 'delete' => '삭제', + 'edit' => 'Endpoint 편집', + 'enable' => 'Endpoint 사용', + 'disable' => 'Endpoint 사용 안 함', + 'rotate' => '서명 시크릿 교체', + 'send_test' => '테스트 이벤트 보내기', + 'replay' => '다시 보내기', + 'reveal_secret' => '시크릿 표시', + 'hide_secret' => '시크릿 숨기기', + 'copy_secret' => '시크릿 복사', + ], + 'create' => [ + 'title' => '웹훅 만들기', + 'description' => '웹훅 알림을 받을 endpoint를 설정하세요.', + 'endpoint' => 'Endpoint URL', + 'endpoint_placeholder' => 'https://example.com/webhooks', + 'events' => '이벤트', + 'events_placeholder' => '이벤트 선택...', + 'events_selected' => '{1} 이벤트 :count개 선택됨|[2,*] 이벤트 :count개 선택됨', + 'search_events' => '이벤트 검색...', + 'no_events' => '이벤트를 찾을 수 없습니다', + 'submit' => '웹훅 만들기', + 'cancel' => '취소', + ], + 'edit' => [ + 'title' => 'Endpoint 편집', + 'description' => 'Endpoint URL과 수신할 이벤트를 업데이트하세요.', + 'submit' => '변경 사항 저장', + 'cancel' => '취소', + ], + 'delete' => [ + 'title' => '웹훅 삭제', + 'description' => '이 웹훅을 삭제할까요? 이 endpoint로는 더 이상 이벤트 알림을 받지 않습니다.', + 'confirm' => '웹훅 삭제', + 'cancel' => '취소', + ], + 'rotate' => [ + 'title' => '서명 시크릿 교체', + 'description' => '새 서명 시크릿이 생성됩니다. 현재 시크릿은 바로 작동을 멈춥니다. Endpoint가 새 시크릿을 쓰도록 업데이트하세요.', + 'submit' => '시크릿 교체', + 'cancel' => '취소', + ], + 'show' => [ + 'signing_secret' => '서명 시크릿', + 'last_sent' => '마지막 전송 :time', + 'listening_for' => '수신 중', + 'http_status' => 'HTTP 상태', + 'status_code' => ':code - :reason', + 'attempts' => '시도', + 'delivered_at' => '전달됨', + 'response_body' => '응답 본문', + 'response' => '응답', + 'no_response_body' => '응답 본문 없음', + 'no_response' => '응답 없음', + 'payload' => '메시지 페이로드', + 'empty_title' => '아직 웹훅 이벤트가 없습니다', + 'empty_description' => '게시물이 생성되거나, 예약되거나, 예약이 취소되거나, 게시되면 웹훅 이벤트가 여기에 나타납니다.', + ], + 'events' => [ + 'group_posts' => '게시물', + 'post_created' => '게시물 생성됨', + 'post_scheduled' => '게시물 예약됨', + 'post_unscheduled' => '예약 취소됨', + 'post_published' => '게시물 게시됨', + 'post_partially_published' => '부분 게시됨', + 'post_failed' => '게시 실패', + 'post_deleted' => '게시물 삭제됨', + ], + 'http_reasons' => [ + 'unknown' => '알 수 없음', + '200' => 'OK', + '201' => '생성됨', + '202' => '수락됨', + '204' => '콘텐츠 없음', + '400' => '잘못된 요청', + '401' => '인증되지 않음', + '403' => '금지됨', + '404' => '찾을 수 없음', + '408' => '요청 시간 초과', + '422' => '처리할 수 없는 엔터티', + '429' => '요청이 너무 많음', + '500' => '내부 서버 오류', + '502' => '잘못된 게이트웨이', + '503' => '서비스를 사용할 수 없음', + '504' => '게이트웨이 시간 초과', + ], + 'copied' => [ + 'id' => '웹훅 ID를 클립보드에 복사했습니다', + 'secret' => '서명 시크릿을 클립보드에 복사했습니다', + 'response' => '응답 본문을 복사했습니다', + 'payload' => '페이로드를 복사했습니다', + ], + 'errors' => [ + 'endpoint_not_allowed' => '이 endpoint는 허용되지 않습니다.', + 'endpoint_unreachable' => 'endpoint에 연결할 수 없습니다.', + 'endpoint_http_status' => 'endpoint가 HTTP :status를 반환했습니다.', + ], + 'flash' => [ + 'created' => '웹훅을 만들었습니다.', + 'updated' => '웹훅을 업데이트했습니다.', + 'deleted' => '웹훅을 삭제했습니다.', + 'secret_rotated' => '서명 시크릿을 교체했습니다.', + 'replayed' => '웹훅 이벤트를 다시 보냈습니다.', + 'tested' => '테스트 이벤트를 보냈습니다.', + ], + 'mail' => [ + 'paused_subject' => '웹훅이 일시정지됨: :endpoint', + 'paused_title' => '반복된 실패로 웹훅이 일시정지되었습니다', + 'paused_preview' => '연속 5회 전달에 실패한 뒤 웹훅을 일시정지했습니다.', + 'paused_body' => ':endpoint의 웹훅을 연속 5회 전달 실패 후 일시정지했습니다. Endpoint를 확인한 뒤 웹훅 상세 페이지에서 다시 사용하세요.', + 'paused_cta' => '웹훅 보기', + ], +]; diff --git a/lang/nl/automations.php b/lang/nl/automations.php index eb5fde1c..90de073a 100644 --- a/lang/nl/automations.php +++ b/lang/nl/automations.php @@ -62,7 +62,6 @@ 'delay' => 'Vertraging', 'condition' => 'Voorwaarde', 'publish' => 'Publiceren', - 'webhook' => 'Webhook', 'end' => 'Einde', 'fetch_rss' => 'RSS ophalen', 'http_request' => 'HTTP-verzoek', @@ -213,7 +212,6 @@ 'delay' => 'Vertraging', 'condition' => 'Voorwaarde', 'publish' => 'Publiceren', - 'webhook' => 'Webhook', 'end' => 'Einde', 'end_summary' => 'Stopt de automatisering hier', 'fetch_rss' => 'RSS ophalen', @@ -326,11 +324,6 @@ 'scheduled_offset' => 'Verschuiving vanaf trigger (minuten)', 'offset_summary' => ':mode · +:offset min', ], - 'webhook' => [ - 'url' => 'URL', - 'method' => 'Methode', - 'payload_template' => 'Payload-sjabloon (JSON)', - ], 'end' => [ 'reason' => 'Reden (optioneel)', 'reason_placeholder' => 'bijv. Uitgefilterd door voorwaarde', @@ -394,10 +387,6 @@ 'graph_contains_cycle' => 'De automatiseringsgraaf bevat een cyclus.', 'only_failed_can_retry' => 'Alleen mislukte uitvoeringen kunnen opnieuw worden geprobeerd.', 'no_generated_post' => 'Geen gegenereerde post gevonden bij de uitvoering.', - 'webhook_server_error' => 'Serverfout bij de webhook.', - 'webhook_request_failed' => 'Het webhookverzoek kon niet worden voltooid.', - 'webhook_missing_url' => 'De webhooknode mist een URL.', - 'webhook_invalid_payload_json' => 'Het payload-sjabloon is geen geldige JSON.', 'url_not_allowed' => 'De verzoek-URL verwijst naar een privé of onbereikbaar adres en is geblokkeerd.', 'node_no_longer_exists' => 'Node :node_id bestaat niet meer in de automatisering.', 'no_trigger_connection' => 'Geen node verbonden met de triggernode.', diff --git a/lang/nl/sidebar.php b/lang/nl/sidebar.php index 212a3c99..65c28fe5 100644 --- a/lang/nl/sidebar.php +++ b/lang/nl/sidebar.php @@ -16,6 +16,7 @@ 'signatures' => 'Handtekeningen', 'labels' => 'Labels', 'assets' => 'Assets', + 'webhooks' => 'Webhooks', 'mcp' => 'MCP', ], 'language' => 'Taal: :name', diff --git a/lang/nl/webhooks.php b/lang/nl/webhooks.php new file mode 100644 index 00000000..08902d8c --- /dev/null +++ b/lang/nl/webhooks.php @@ -0,0 +1,139 @@ + 'Webhooks', + 'description' => 'Ontvang realtime meldingen wanneer posts worden aangemaakt, gepland, ontpland, gepubliceerd of mislukken.', + 'new' => 'Webhook maken', + 'empty_title' => 'Nog geen webhooks', + 'empty_description' => 'Maak een webhook om realtime gebeurtenismeldingen te ontvangen.', + 'table' => [ + 'endpoint' => 'Endpoint', + 'events' => 'Luistert naar', + 'status' => 'Status', + 'last_sent' => 'Laatst verzonden', + ], + 'events_count' => '{1} :count gebeurtenis|[2,*] :count gebeurtenissen', + 'never' => 'Nooit', + 'status' => [ + 'enabled' => 'Actief', + 'disabled' => 'Uitgeschakeld', + 'paused' => 'Gepauzeerd', + ], + 'actions' => [ + 'view' => 'Details bekijken', + 'copy_id' => 'Webhook-ID kopiëren', + 'delete' => 'Verwijderen', + 'edit' => 'Endpoint bewerken', + 'enable' => 'Endpoint inschakelen', + 'disable' => 'Endpoint uitschakelen', + 'rotate' => 'Ondertekeningssecret roteren', + 'send_test' => 'Testgebeurtenis versturen', + 'replay' => 'Opnieuw versturen', + 'reveal_secret' => 'Secret tonen', + 'hide_secret' => 'Secret verbergen', + 'copy_secret' => 'Secret kopiëren', + ], + 'create' => [ + 'title' => 'Webhook maken', + 'description' => 'Stel een endpoint in om webhookmeldingen te ontvangen.', + 'endpoint' => 'Endpoint-URL', + 'endpoint_placeholder' => 'https://example.com/webhooks', + 'events' => 'Gebeurtenissen', + 'events_placeholder' => 'Gebeurtenissen selecteren...', + 'events_selected' => '{1} :count gebeurtenis geselecteerd|[2,*] :count gebeurtenissen geselecteerd', + 'search_events' => 'Gebeurtenissen zoeken...', + 'no_events' => 'Geen gebeurtenissen gevonden', + 'submit' => 'Webhook maken', + 'cancel' => 'Annuleren', + ], + 'edit' => [ + 'title' => 'Endpoint bewerken', + 'description' => 'Werk de endpoint-URL en de gebeurtenissen om naar te luisteren bij.', + 'submit' => 'Wijzigingen opslaan', + 'cancel' => 'Annuleren', + ], + 'delete' => [ + 'title' => 'Webhook verwijderen', + 'description' => 'Weet je zeker dat je deze webhook wilt verwijderen? Je ontvangt geen gebeurtenismeldingen meer op dit endpoint.', + 'confirm' => 'Webhook verwijderen', + 'cancel' => 'Annuleren', + ], + 'rotate' => [ + 'title' => 'Ondertekeningssecret roteren', + 'description' => 'Dit genereert een nieuw ondertekeningssecret. Het huidige secret stopt meteen met werken. Werk je endpoint bij om het nieuwe secret te gebruiken.', + 'submit' => 'Secret roteren', + 'cancel' => 'Annuleren', + ], + 'show' => [ + 'signing_secret' => 'Ondertekeningssecret', + 'last_sent' => 'Laatst verzonden :time', + 'listening_for' => 'Luistert naar', + 'http_status' => 'HTTP-status', + 'status_code' => ':code - :reason', + 'attempts' => 'Pogingen', + 'delivered_at' => 'Afgeleverd om', + 'response_body' => 'Antwoordtekst', + 'response' => 'Antwoord', + 'no_response_body' => 'Geen antwoordtekst', + 'no_response' => 'Geen antwoord', + 'payload' => 'Berichtpayload', + 'empty_title' => 'Nog geen webhookgebeurtenissen', + 'empty_description' => 'Zodra posts worden aangemaakt, gepland, ontpland of gepubliceerd, zie je de webhookgebeurtenissen hier.', + ], + 'events' => [ + 'group_posts' => 'Posts', + 'post_created' => 'Post aangemaakt', + 'post_scheduled' => 'Post gepland', + 'post_unscheduled' => 'Planning opgeheven', + 'post_published' => 'Post gepubliceerd', + 'post_partially_published' => 'Post gedeeltelijk gepubliceerd', + 'post_failed' => 'Post mislukt', + 'post_deleted' => 'Post verwijderd', + ], + 'http_reasons' => [ + 'unknown' => 'Onbekend', + '200' => 'OK', + '201' => 'Aangemaakt', + '202' => 'Geaccepteerd', + '204' => 'Geen inhoud', + '400' => 'Ongeldige aanvraag', + '401' => 'Niet geautoriseerd', + '403' => 'Verboden', + '404' => 'Niet gevonden', + '408' => 'Time-out', + '422' => 'Niet verwerkbaar', + '429' => 'Te veel verzoeken', + '500' => 'Interne serverfout', + '502' => 'Ongeldige gateway', + '503' => 'Dienst niet beschikbaar', + '504' => 'Gateway-time-out', + ], + 'copied' => [ + 'id' => 'Webhook-ID naar klembord gekopieerd', + 'secret' => 'Ondertekeningssecret naar klembord gekopieerd', + 'response' => 'Antwoordtekst gekopieerd', + 'payload' => 'Payload gekopieerd', + ], + 'errors' => [ + 'endpoint_not_allowed' => 'Dit endpoint is niet toegestaan.', + 'endpoint_unreachable' => 'Het endpoint is niet bereikbaar.', + 'endpoint_http_status' => 'Het endpoint gaf HTTP :status terug.', + ], + 'flash' => [ + 'created' => 'Webhook gemaakt.', + 'updated' => 'Webhook bijgewerkt.', + 'deleted' => 'Webhook verwijderd.', + 'secret_rotated' => 'Ondertekeningssecret geroteerd.', + 'replayed' => 'Webhookgebeurtenis opnieuw verstuurd.', + 'tested' => 'Testgebeurtenis verstuurd.', + ], + 'mail' => [ + 'paused_subject' => 'Webhook gepauzeerd: :endpoint', + 'paused_title' => 'Webhook gepauzeerd na herhaalde fouten', + 'paused_preview' => 'We hebben een webhook gepauzeerd na 5 opeenvolgende afleverfouten.', + 'paused_body' => 'We hebben de webhook op :endpoint gepauzeerd na 5 opeenvolgende afleverfouten. Controleer het endpoint en schakel het weer in op de webhookdetailpagina.', + 'paused_cta' => 'Webhook bekijken', + ], +]; diff --git a/lang/pl/automations.php b/lang/pl/automations.php index 5a898471..c968ff74 100644 --- a/lang/pl/automations.php +++ b/lang/pl/automations.php @@ -62,7 +62,6 @@ 'delay' => 'Opóźnienie', 'condition' => 'Warunek', 'publish' => 'Publikuj', - 'webhook' => 'Webhook', 'end' => 'Koniec', 'fetch_rss' => 'Pobierz RSS', 'http_request' => 'Żądanie HTTP', @@ -213,7 +212,6 @@ 'delay' => 'Opóźnienie', 'condition' => 'Warunek', 'publish' => 'Publikuj', - 'webhook' => 'Webhook', 'end' => 'Koniec', 'end_summary' => 'Zatrzymuje automatyzację w tym miejscu', 'fetch_rss' => 'Pobierz RSS', @@ -326,11 +324,6 @@ 'scheduled_offset' => 'Przesunięcie od wyzwolenia (minuty)', 'offset_summary' => ':mode · +:offset min', ], - 'webhook' => [ - 'url' => 'URL', - 'method' => 'Metoda', - 'payload_template' => 'Szablon ładunku (JSON)', - ], 'end' => [ 'reason' => 'Powód (opcjonalnie)', 'reason_placeholder' => 'np. Odfiltrowane przez warunek', @@ -394,10 +387,6 @@ 'graph_contains_cycle' => 'Graf automatyzacji zawiera cykl.', 'only_failed_can_retry' => 'Tylko nieudane uruchomienia można ponowić.', 'no_generated_post' => 'Nie znaleziono wygenerowanego posta w uruchomieniu.', - 'webhook_server_error' => 'Błąd serwera webhooka.', - 'webhook_request_failed' => 'Nie udało się zrealizować żądania webhooka.', - 'webhook_missing_url' => 'W węźle webhooka brakuje adresu URL.', - 'webhook_invalid_payload_json' => 'Szablon ładunku nie jest prawidłowym JSON-em.', 'url_not_allowed' => 'Adres URL żądania wskazuje na prywatny lub nieosiągalny adres i został zablokowany.', 'node_no_longer_exists' => 'Węzeł :node_id już nie istnieje w automatyzacji.', 'no_trigger_connection' => 'Żaden węzeł nie jest połączony z węzłem wyzwalacza.', diff --git a/lang/pl/sidebar.php b/lang/pl/sidebar.php index f3bed7b8..e62c03f4 100644 --- a/lang/pl/sidebar.php +++ b/lang/pl/sidebar.php @@ -16,6 +16,7 @@ 'signatures' => 'Sygnatury', 'labels' => 'Etykiety', 'assets' => 'Zasoby', + 'webhooks' => 'Webhooki', 'mcp' => 'MCP', ], 'language' => 'Język: :name', diff --git a/lang/pl/webhooks.php b/lang/pl/webhooks.php new file mode 100644 index 00000000..85051201 --- /dev/null +++ b/lang/pl/webhooks.php @@ -0,0 +1,139 @@ + 'Webhooks', + 'description' => 'Otrzymuj powiadomienia w czasie rzeczywistym, gdy posty są tworzone, planowane, odwoływane z planu, publikowane lub kończą się niepowodzeniem.', + 'new' => 'Utwórz webhook', + 'empty_title' => 'Nie ma jeszcze webhooków', + 'empty_description' => 'Utwórz webhook, aby otrzymywać powiadomienia o zdarzeniach w czasie rzeczywistym.', + 'table' => [ + 'endpoint' => 'Endpoint', + 'events' => 'Nasłuchuje', + 'status' => 'Status', + 'last_sent' => 'Ostatnio wysłano', + ], + 'events_count' => '{1} :count zdarzenie|[2,*] :count zdarzeń', + 'never' => 'Nigdy', + 'status' => [ + 'enabled' => 'Aktywny', + 'disabled' => 'Wyłączony', + 'paused' => 'Wstrzymany', + ], + 'actions' => [ + 'view' => 'Zobacz szczegóły', + 'copy_id' => 'Kopiuj ID webhooka', + 'delete' => 'Usuń', + 'edit' => 'Edytuj endpoint', + 'enable' => 'Włącz endpoint', + 'disable' => 'Wyłącz endpoint', + 'rotate' => 'Obróć secret podpisu', + 'send_test' => 'Wyślij zdarzenie testowe', + 'replay' => 'Wyślij ponownie', + 'reveal_secret' => 'Pokaż secret', + 'hide_secret' => 'Ukryj secret', + 'copy_secret' => 'Kopiuj secret', + ], + 'create' => [ + 'title' => 'Utwórz webhook', + 'description' => 'Skonfiguruj endpoint, aby otrzymywać powiadomienia webhook.', + 'endpoint' => 'URL endpointu', + 'endpoint_placeholder' => 'https://example.com/webhooks', + 'events' => 'Zdarzenia', + 'events_placeholder' => 'Wybierz zdarzenia...', + 'events_selected' => '{1} :count zdarzenie wybrane|[2,*] :count zdarzeń wybranych', + 'search_events' => 'Szukaj zdarzeń...', + 'no_events' => 'Nie znaleziono zdarzeń', + 'submit' => 'Utwórz webhook', + 'cancel' => 'Anuluj', + ], + 'edit' => [ + 'title' => 'Edytuj endpoint', + 'description' => 'Zaktualizuj URL endpointu i zdarzenia do nasłuchiwania.', + 'submit' => 'Zapisz zmiany', + 'cancel' => 'Anuluj', + ], + 'delete' => [ + 'title' => 'Usuń webhook', + 'description' => 'Czy na pewno chcesz usunąć ten webhook? Nie będziesz już otrzymywać powiadomień o zdarzeniach na tym endpoincie.', + 'confirm' => 'Usuń webhook', + 'cancel' => 'Anuluj', + ], + 'rotate' => [ + 'title' => 'Obróć secret podpisu', + 'description' => 'To wygeneruje nowy secret podpisu. Obecny secret przestanie działać od razu. Zaktualizuj swój endpoint, aby używał nowego secretu.', + 'submit' => 'Obróć secret', + 'cancel' => 'Anuluj', + ], + 'show' => [ + 'signing_secret' => 'Secret podpisu', + 'last_sent' => 'Ostatnio wysłano :time', + 'listening_for' => 'Nasłuchuje', + 'http_status' => 'Status HTTP', + 'status_code' => ':code - :reason', + 'attempts' => 'Próby', + 'delivered_at' => 'Dostarczono', + 'response_body' => 'Treść odpowiedzi', + 'response' => 'Odpowiedź', + 'no_response_body' => 'Brak treści odpowiedzi', + 'no_response' => 'Brak odpowiedzi', + 'payload' => 'Payload wiadomości', + 'empty_title' => 'Nie ma jeszcze zdarzeń', + 'empty_description' => 'Gdy posty zostaną utworzone, zaplanowane, odwołane z planu lub opublikowane, zdarzenia webhooka pojawią się tutaj.', + ], + 'events' => [ + 'group_posts' => 'Posty', + 'post_created' => 'Post utworzony', + 'post_scheduled' => 'Post zaplanowany', + 'post_unscheduled' => 'Planowanie anulowane', + 'post_published' => 'Post opublikowany', + 'post_partially_published' => 'Post częściowo opublikowany', + 'post_failed' => 'Post nieudany', + 'post_deleted' => 'Post usunięty', + ], + 'http_reasons' => [ + 'unknown' => 'Nieznany', + '200' => 'OK', + '201' => 'Utworzono', + '202' => 'Zaakceptowano', + '204' => 'Brak treści', + '400' => 'Nieprawidłowe żądanie', + '401' => 'Nieautoryzowany', + '403' => 'Zabronione', + '404' => 'Nie znaleziono', + '408' => 'Przekroczono czas', + '422' => 'Nieprzetwarzalna encja', + '429' => 'Zbyt wiele żądań', + '500' => 'Wewnętrzny błąd serwera', + '502' => 'Nieprawidłowa brama', + '503' => 'Usługa niedostępna', + '504' => 'Przekroczono czas bramy', + ], + 'copied' => [ + 'id' => 'ID webhooka skopiowane do schowka', + 'secret' => 'Secret podpisu skopiowany do schowka', + 'response' => 'Treść odpowiedzi skopiowana', + 'payload' => 'Payload skopiowany', + ], + 'errors' => [ + 'endpoint_not_allowed' => 'Ten endpoint nie jest dozwolony.', + 'endpoint_unreachable' => 'Endpoint jest nieosiągalny.', + 'endpoint_http_status' => 'Endpoint zwrócił HTTP :status.', + ], + 'flash' => [ + 'created' => 'Webhook utworzony.', + 'updated' => 'Webhook zaktualizowany.', + 'deleted' => 'Webhook usunięty.', + 'secret_rotated' => 'Secret podpisu obrócony.', + 'replayed' => 'Zdarzenie webhooka wysłane ponownie.', + 'tested' => 'Wysłano zdarzenie testowe.', + ], + 'mail' => [ + 'paused_subject' => 'Webhook wstrzymany: :endpoint', + 'paused_title' => 'Webhook wstrzymany po powtarzających się błędach', + 'paused_preview' => 'Wstrzymaliśmy webhook po 5 kolejnych błędach dostarczenia.', + 'paused_body' => 'Wstrzymaliśmy webhook pod adresem :endpoint po 5 kolejnych błędach dostarczenia. Sprawdź endpoint i włącz go ponownie na stronie szczegółów webhooka.', + 'paused_cta' => 'Zobacz webhook', + ], +]; diff --git a/lang/pt-BR/automations.php b/lang/pt-BR/automations.php index 3603ca2a..81c5f9c1 100644 --- a/lang/pt-BR/automations.php +++ b/lang/pt-BR/automations.php @@ -62,7 +62,6 @@ 'delay' => 'Espera', 'condition' => 'Condição', 'publish' => 'Publicar', - 'webhook' => 'Webhook', 'end' => 'Fim', 'fetch_rss' => 'Buscar RSS', 'http_request' => 'Requisição HTTP', @@ -213,7 +212,6 @@ 'delay' => 'Esperar', 'condition' => 'Condição', 'publish' => 'Publicar', - 'webhook' => 'Webhook', 'end' => 'Encerrar', 'end_summary' => 'Encerra a automação aqui', 'fetch_rss' => 'Buscar RSS', @@ -326,11 +324,6 @@ 'scheduled_offset' => 'Atraso a partir do trigger (minutos)', 'offset_summary' => ':mode · +:offset min', ], - 'webhook' => [ - 'url' => 'URL', - 'method' => 'Método', - 'payload_template' => 'Template do payload (JSON)', - ], 'end' => [ 'reason' => 'Motivo (opcional)', 'reason_placeholder' => 'ex: Filtrado pela condição', @@ -394,10 +387,6 @@ 'graph_contains_cycle' => 'O grafo da automação contém um ciclo.', 'only_failed_can_retry' => 'Apenas execuções que falharam podem ser repetidas.', 'no_generated_post' => 'Nenhum post gerado encontrado para esta execução.', - 'webhook_server_error' => 'Erro no servidor do webhook.', - 'webhook_request_failed' => 'Não foi possível completar a requisição do webhook.', - 'webhook_missing_url' => 'O nó de webhook está sem a URL.', - 'webhook_invalid_payload_json' => 'O template do payload não é um JSON válido.', 'url_not_allowed' => 'A URL da requisição aponta para um endereço privado ou inacessível e foi bloqueada.', 'node_no_longer_exists' => 'O nó :node_id não existe mais nesta automação.', 'no_trigger_connection' => 'Nenhum nó conectado ao nó de trigger.', diff --git a/lang/pt-BR/sidebar.php b/lang/pt-BR/sidebar.php index ed7f13a8..7ddc2866 100644 --- a/lang/pt-BR/sidebar.php +++ b/lang/pt-BR/sidebar.php @@ -16,6 +16,7 @@ 'signatures' => 'Assinaturas', 'labels' => 'Etiquetas', 'assets' => 'Mídias', + 'webhooks' => 'Webhooks', 'mcp' => 'MCP', ], 'language' => 'Idioma: :name', diff --git a/lang/pt-BR/webhooks.php b/lang/pt-BR/webhooks.php new file mode 100644 index 00000000..edc96768 --- /dev/null +++ b/lang/pt-BR/webhooks.php @@ -0,0 +1,139 @@ + 'Webhooks', + 'description' => 'Receba notificações em tempo real quando posts forem criados, agendados, desagendados, publicados ou falharem.', + 'new' => 'Criar webhook', + 'empty_title' => 'Nenhum webhook ainda', + 'empty_description' => 'Crie um webhook para receber notificações de eventos em tempo real.', + 'table' => [ + 'endpoint' => 'Endpoint', + 'events' => 'Escutando', + 'status' => 'Status', + 'last_sent' => 'Último envio', + ], + 'events_count' => '{1} :count evento|[2,*] :count eventos', + 'never' => 'Nunca', + 'status' => [ + 'enabled' => 'Ativo', + 'disabled' => 'Desativado', + 'paused' => 'Pausado', + ], + 'actions' => [ + 'view' => 'Ver detalhes', + 'copy_id' => 'Copiar ID do webhook', + 'delete' => 'Excluir', + 'edit' => 'Editar endpoint', + 'enable' => 'Ativar endpoint', + 'disable' => 'Desativar endpoint', + 'rotate' => 'Rotacionar secret de assinatura', + 'send_test' => 'Enviar evento de teste', + 'replay' => 'Reenviar', + 'reveal_secret' => 'Mostrar secret', + 'hide_secret' => 'Ocultar secret', + 'copy_secret' => 'Copiar secret', + ], + 'create' => [ + 'title' => 'Criar webhook', + 'description' => 'Configure um endpoint para receber notificações de webhook.', + 'endpoint' => 'URL do endpoint', + 'endpoint_placeholder' => 'https://example.com/webhooks', + 'events' => 'Eventos', + 'events_placeholder' => 'Selecionar eventos...', + 'events_selected' => '{1} :count evento selecionado|[2,*] :count eventos selecionados', + 'search_events' => 'Buscar eventos...', + 'no_events' => 'Nenhum evento encontrado', + 'submit' => 'Criar webhook', + 'cancel' => 'Cancelar', + ], + 'edit' => [ + 'title' => 'Editar endpoint', + 'description' => 'Atualize a URL do endpoint e os eventos a escutar.', + 'submit' => 'Salvar alterações', + 'cancel' => 'Cancelar', + ], + 'delete' => [ + 'title' => 'Excluir webhook', + 'description' => 'Tem certeza de que deseja excluir este webhook? Você não receberá mais notificações neste endpoint.', + 'confirm' => 'Excluir webhook', + 'cancel' => 'Cancelar', + ], + 'rotate' => [ + 'title' => 'Rotacionar secret de assinatura', + 'description' => 'Isso gera um novo secret de assinatura. O secret atual para de funcionar imediatamente. Atualize seu endpoint para usar o novo secret.', + 'submit' => 'Rotacionar secret', + 'cancel' => 'Cancelar', + ], + 'show' => [ + 'signing_secret' => 'Secret de assinatura', + 'last_sent' => 'Último envio :time', + 'listening_for' => 'Escutando', + 'http_status' => 'Status HTTP', + 'status_code' => ':code - :reason', + 'attempts' => 'Tentativas', + 'delivered_at' => 'Entregue em', + 'response_body' => 'Corpo da resposta', + 'response' => 'Resposta', + 'no_response_body' => 'Sem corpo de resposta', + 'no_response' => 'Sem resposta', + 'payload' => 'Payload da mensagem', + 'empty_title' => 'Nenhum evento ainda', + 'empty_description' => 'Quando posts forem criados, agendados, desagendados ou publicados, os eventos do webhook aparecem aqui.', + ], + 'events' => [ + 'group_posts' => 'Posts', + 'post_created' => 'Post criado', + 'post_scheduled' => 'Post agendado', + 'post_unscheduled' => 'Post desagendado', + 'post_published' => 'Post publicado', + 'post_partially_published' => 'Post parcialmente publicado', + 'post_failed' => 'Post falhou', + 'post_deleted' => 'Post excluído', + ], + 'http_reasons' => [ + 'unknown' => 'Desconhecido', + '200' => 'OK', + '201' => 'Criado', + '202' => 'Aceito', + '204' => 'Sem conteúdo', + '400' => 'Requisição inválida', + '401' => 'Não autorizado', + '403' => 'Proibido', + '404' => 'Não encontrado', + '408' => 'Tempo esgotado', + '422' => 'Entidade não processável', + '429' => 'Muitas requisições', + '500' => 'Erro interno do servidor', + '502' => 'Gateway inválido', + '503' => 'Serviço indisponível', + '504' => 'Tempo esgotado do gateway', + ], + 'copied' => [ + 'id' => 'ID do webhook copiado', + 'secret' => 'Secret de assinatura copiado', + 'response' => 'Corpo da resposta copiado', + 'payload' => 'Payload copiado', + ], + 'errors' => [ + 'endpoint_not_allowed' => 'Este endpoint não é permitido.', + 'endpoint_unreachable' => 'O endpoint não está acessível.', + 'endpoint_http_status' => 'O endpoint retornou HTTP :status.', + ], + 'flash' => [ + 'created' => 'Webhook criado.', + 'updated' => 'Webhook atualizado.', + 'deleted' => 'Webhook excluído.', + 'secret_rotated' => 'Secret de assinatura rotacionado.', + 'replayed' => 'Evento do webhook reenviado.', + 'tested' => 'Evento de teste enviado.', + ], + 'mail' => [ + 'paused_subject' => 'Webhook pausado: :endpoint', + 'paused_title' => 'Webhook pausado após falhas repetidas', + 'paused_preview' => 'Pausamos um webhook após 5 falhas consecutivas de entrega.', + 'paused_body' => 'Pausamos o webhook em :endpoint após 5 falhas consecutivas de entrega. Revise o endpoint e reative-o na página de detalhes do webhook.', + 'paused_cta' => 'Ver webhook', + ], +]; diff --git a/lang/ru/automations.php b/lang/ru/automations.php index 1de196ba..9331e324 100644 --- a/lang/ru/automations.php +++ b/lang/ru/automations.php @@ -62,7 +62,6 @@ 'delay' => 'Задержка', 'condition' => 'Условие', 'publish' => 'Публикация', - 'webhook' => 'Webhook', 'end' => 'Конец', 'fetch_rss' => 'Получить RSS', 'http_request' => 'HTTP-запрос', @@ -213,7 +212,6 @@ 'delay' => 'Задержка', 'condition' => 'Условие', 'publish' => 'Публикация', - 'webhook' => 'Webhook', 'end' => 'Конец', 'end_summary' => 'Останавливает автоматизацию здесь', 'fetch_rss' => 'Получить RSS', @@ -326,11 +324,6 @@ 'scheduled_offset' => 'Смещение от триггера (минуты)', 'offset_summary' => ':mode · +:offset мин', ], - 'webhook' => [ - 'url' => 'URL', - 'method' => 'Метод', - 'payload_template' => 'Шаблон полезной нагрузки (JSON)', - ], 'end' => [ 'reason' => 'Причина (необязательно)', 'reason_placeholder' => 'например, Отфильтровано условием', @@ -394,10 +387,6 @@ 'graph_contains_cycle' => 'Граф автоматизации содержит цикл.', 'only_failed_can_retry' => 'Повторить можно только запуски, завершившиеся с ошибкой.', 'no_generated_post' => 'В запуске не найдено сгенерированного поста.', - 'webhook_server_error' => 'Ошибка сервера webhook.', - 'webhook_request_failed' => 'Не удалось выполнить запрос webhook.', - 'webhook_missing_url' => 'В узле webhook отсутствует URL.', - 'webhook_invalid_payload_json' => 'Шаблон полезной нагрузки не является корректным JSON.', 'url_not_allowed' => 'URL запроса указывает на приватный или недоступный адрес и был заблокирован.', 'node_no_longer_exists' => 'Узел :node_id больше не существует в автоматизации.', 'no_trigger_connection' => 'К узлу-триггеру не подключён ни один узел.', diff --git a/lang/ru/sidebar.php b/lang/ru/sidebar.php index eeedb76f..d0eeaa9b 100644 --- a/lang/ru/sidebar.php +++ b/lang/ru/sidebar.php @@ -16,6 +16,7 @@ 'signatures' => 'Подписи', 'labels' => 'Метки', 'assets' => 'Медиафайлы', + 'webhooks' => 'Вебхуки', 'mcp' => 'MCP', ], 'language' => 'Язык: :name', diff --git a/lang/ru/webhooks.php b/lang/ru/webhooks.php new file mode 100644 index 00000000..9ea6b048 --- /dev/null +++ b/lang/ru/webhooks.php @@ -0,0 +1,139 @@ + 'Webhooks', + 'description' => 'Получайте уведомления в реальном времени, когда посты создаются, планируются, снимаются с плана, публикуются или завершаются ошибкой.', + 'new' => 'Создать вебхук', + 'empty_title' => 'Вебхуков пока нет', + 'empty_description' => 'Создайте вебхук, чтобы получать уведомления о событиях в реальном времени.', + 'table' => [ + 'endpoint' => 'Endpoint', + 'events' => 'Слушает', + 'status' => 'Статус', + 'last_sent' => 'Последняя отправка', + ], + 'events_count' => '{1} :count событие|[2,*] :count событий', + 'never' => 'Никогда', + 'status' => [ + 'enabled' => 'Включён', + 'disabled' => 'Отключён', + 'paused' => 'Приостановлен', + ], + 'actions' => [ + 'view' => 'Подробности', + 'copy_id' => 'Скопировать ID вебхука', + 'delete' => 'Удалить', + 'edit' => 'Изменить endpoint', + 'enable' => 'Включить endpoint', + 'disable' => 'Отключить endpoint', + 'rotate' => 'Сменить секрет подписи', + 'send_test' => 'Отправить тестовое событие', + 'replay' => 'Отправить снова', + 'reveal_secret' => 'Показать секрет', + 'hide_secret' => 'Скрыть секрет', + 'copy_secret' => 'Скопировать секрет', + ], + 'create' => [ + 'title' => 'Создать вебхук', + 'description' => 'Настройте endpoint для получения уведомлений вебхука.', + 'endpoint' => 'URL endpoint', + 'endpoint_placeholder' => 'https://example.com/webhooks', + 'events' => 'События', + 'events_placeholder' => 'Выберите события...', + 'events_selected' => '{1} :count событие выбрано|[2,*] :count событий выбрано', + 'search_events' => 'Поиск событий...', + 'no_events' => 'События не найдены', + 'submit' => 'Создать вебхук', + 'cancel' => 'Отмена', + ], + 'edit' => [ + 'title' => 'Изменить endpoint', + 'description' => 'Обновите URL endpoint и события для прослушивания.', + 'submit' => 'Сохранить изменения', + 'cancel' => 'Отмена', + ], + 'delete' => [ + 'title' => 'Удалить вебхук', + 'description' => 'Удалить этот вебхук? Вы больше не будете получать уведомления о событиях на этом endpoint.', + 'confirm' => 'Удалить вебхук', + 'cancel' => 'Отмена', + ], + 'rotate' => [ + 'title' => 'Сменить секрет подписи', + 'description' => 'Будет создан новый секрет подписи. Текущий секрет сразу перестанет работать. Обновите endpoint, чтобы использовать новый секрет.', + 'submit' => 'Сменить секрет', + 'cancel' => 'Отмена', + ], + 'show' => [ + 'signing_secret' => 'Секрет подписи', + 'last_sent' => 'Последняя отправка :time', + 'listening_for' => 'Слушает', + 'http_status' => 'Статус HTTP', + 'status_code' => ':code - :reason', + 'attempts' => 'Попытки', + 'delivered_at' => 'Доставлено', + 'response_body' => 'Тело ответа', + 'response' => 'Ответ', + 'no_response_body' => 'Нет тела ответа', + 'no_response' => 'Нет ответа', + 'payload' => 'Payload сообщения', + 'empty_title' => 'Событий пока нет', + 'empty_description' => 'Когда посты создаются, планируются, снимаются с плана или публикуются, события вебхука появятся здесь.', + ], + 'events' => [ + 'group_posts' => 'Посты', + 'post_created' => 'Пост создан', + 'post_scheduled' => 'Пост запланирован', + 'post_unscheduled' => 'Планирование отменено', + 'post_published' => 'Пост опубликован', + 'post_partially_published' => 'Пост частично опубликован', + 'post_failed' => 'Ошибка публикации', + 'post_deleted' => 'Пост удалён', + ], + 'http_reasons' => [ + 'unknown' => 'Неизвестно', + '200' => 'OK', + '201' => 'Создано', + '202' => 'Принято', + '204' => 'Нет содержимого', + '400' => 'Неверный запрос', + '401' => 'Не авторизован', + '403' => 'Запрещено', + '404' => 'Не найдено', + '408' => 'Истекло время ожидания', + '422' => 'Необрабатываемая сущность', + '429' => 'Слишком много запросов', + '500' => 'Внутренняя ошибка сервера', + '502' => 'Ошибочный шлюз', + '503' => 'Сервис недоступен', + '504' => 'Истекло время шлюза', + ], + 'copied' => [ + 'id' => 'ID вебхука скопирован в буфер обмена', + 'secret' => 'Секрет подписи скопирован в буфер обмена', + 'response' => 'Тело ответа скопировано', + 'payload' => 'Payload скопирован', + ], + 'errors' => [ + 'endpoint_not_allowed' => 'Этот endpoint не разрешён.', + 'endpoint_unreachable' => 'Endpoint недоступен.', + 'endpoint_http_status' => 'Endpoint вернул HTTP :status.', + ], + 'flash' => [ + 'created' => 'Вебхук создан.', + 'updated' => 'Вебхук обновлён.', + 'deleted' => 'Вебхук удалён.', + 'secret_rotated' => 'Секрет подписи изменён.', + 'replayed' => 'Событие вебхука отправлено снова.', + 'tested' => 'Тестовое событие отправлено.', + ], + 'mail' => [ + 'paused_subject' => 'Вебхук приостановлен: :endpoint', + 'paused_title' => 'Вебхук приостановлен после повторных ошибок', + 'paused_preview' => 'Мы приостановили вебхук после 5 подряд неудачных доставок.', + 'paused_body' => 'Мы приостановили вебхук на :endpoint после 5 подряд неудачных доставок. Проверьте endpoint и включите его снова на странице вебхука.', + 'paused_cta' => 'Открыть вебхук', + ], +]; diff --git a/lang/tr/automations.php b/lang/tr/automations.php index 402d06c3..cbbd1e7e 100644 --- a/lang/tr/automations.php +++ b/lang/tr/automations.php @@ -62,7 +62,6 @@ 'delay' => 'Gecikme', 'condition' => 'Koşul', 'publish' => 'Yayınla', - 'webhook' => 'Webhook', 'end' => 'Bitir', 'fetch_rss' => 'RSS Getir', 'http_request' => 'HTTP isteği', @@ -213,7 +212,6 @@ 'delay' => 'Gecikme', 'condition' => 'Koşul', 'publish' => 'Yayınla', - 'webhook' => 'Webhook', 'end' => 'Bitir', 'end_summary' => 'Otomasyonu burada durdurur', 'fetch_rss' => 'RSS Getir', @@ -326,11 +324,6 @@ 'scheduled_offset' => 'Tetikleyiciden kayma (dakika)', 'offset_summary' => ':mode · +:offset dk', ], - 'webhook' => [ - 'url' => 'URL', - 'method' => 'Yöntem', - 'payload_template' => 'Yük şablonu (JSON)', - ], 'end' => [ 'reason' => 'Neden (isteğe bağlı)', 'reason_placeholder' => 'örn. Koşul tarafından filtrelendi', @@ -394,10 +387,6 @@ 'graph_contains_cycle' => 'Otomasyon grafiği bir döngü içeriyor.', 'only_failed_can_retry' => 'Yalnızca başarısız çalıştırmalar yeniden denenebilir.', 'no_generated_post' => 'Çalıştırmada oluşturulmuş gönderi bulunamadı.', - 'webhook_server_error' => 'Webhook sunucu hatası.', - 'webhook_request_failed' => 'Webhook isteği tamamlanamadı.', - 'webhook_missing_url' => 'Webhook düğümünde URL eksik.', - 'webhook_invalid_payload_json' => 'Yük şablonu geçerli JSON değil.', 'url_not_allowed' => 'İstek URL\'si özel veya erişilemez bir adrese işaret ediyor ve engellendi.', 'node_no_longer_exists' => ':node_id düğümü artık otomasyonda yok.', 'no_trigger_connection' => 'Tetikleyici düğümüne bağlı düğüm yok.', diff --git a/lang/tr/sidebar.php b/lang/tr/sidebar.php index 64a8532c..4084d3ff 100644 --- a/lang/tr/sidebar.php +++ b/lang/tr/sidebar.php @@ -16,6 +16,7 @@ 'signatures' => 'İmzalar', 'labels' => 'Etiketler', 'assets' => 'Varlıklar', + 'webhooks' => 'Webhooklar', 'mcp' => 'MCP', ], 'language' => 'Dil: :name', diff --git a/lang/tr/webhooks.php b/lang/tr/webhooks.php new file mode 100644 index 00000000..e7754c62 --- /dev/null +++ b/lang/tr/webhooks.php @@ -0,0 +1,139 @@ + 'Webhooks', + 'description' => 'Gönderiler oluşturulduğunda, zamanlandığında, zamanlaması kaldırıldığında, yayınlandığında veya başarısız olduğunda anlık bildirim alın.', + 'new' => 'Webhook oluştur', + 'empty_title' => 'Henüz webhook yok', + 'empty_description' => 'Anlık olay bildirimleri almak için bir webhook oluşturun.', + 'table' => [ + 'endpoint' => 'Endpoint', + 'events' => 'Dinlenenler', + 'status' => 'Durum', + 'last_sent' => 'Son gönderim', + ], + 'events_count' => '{1} :count olay|[2,*] :count olay', + 'never' => 'Hiçbir zaman', + 'status' => [ + 'enabled' => 'Etkin', + 'disabled' => 'Devre dışı', + 'paused' => 'Duraklatıldı', + ], + 'actions' => [ + 'view' => 'Ayrıntıları gör', + 'copy_id' => 'Webhook kimliğini kopyala', + 'delete' => 'Sil', + 'edit' => 'Endpointi düzenle', + 'enable' => 'Endpointi etkinleştir', + 'disable' => 'Endpointi devre dışı bırak', + 'rotate' => 'İmza secretini yenile', + 'send_test' => 'Test olayı gönder', + 'replay' => 'Yeniden gönder', + 'reveal_secret' => 'Secreti göster', + 'hide_secret' => 'Secreti gizle', + 'copy_secret' => 'Secreti kopyala', + ], + 'create' => [ + 'title' => 'Webhook oluştur', + 'description' => 'Webhook bildirimleri almak için bir endpoint yapılandırın.', + 'endpoint' => 'Endpoint URL\'si', + 'endpoint_placeholder' => 'https://example.com/webhooks', + 'events' => 'Olaylar', + 'events_placeholder' => 'Olay seçin...', + 'events_selected' => '{1} :count olay seçildi|[2,*] :count olay seçildi', + 'search_events' => 'Olay ara...', + 'no_events' => 'Olay bulunamadı', + 'submit' => 'Webhook oluştur', + 'cancel' => 'İptal', + ], + 'edit' => [ + 'title' => 'Endpointi düzenle', + 'description' => 'Endpoint URL\'sini ve dinlenecek olayları güncelleyin.', + 'submit' => 'Değişiklikleri kaydet', + 'cancel' => 'İptal', + ], + 'delete' => [ + 'title' => 'Webhooku sil', + 'description' => 'Bu webhooku silmek istediğinize emin misiniz? Bu endpointte artık olay bildirimleri almazsınız.', + 'confirm' => 'Webhooku sil', + 'cancel' => 'İptal', + ], + 'rotate' => [ + 'title' => 'İmza secretini yenile', + 'description' => 'Bu işlem yeni bir imza secreti üretir. Mevcut secret hemen çalışmayı durdurur. Endpointinizi yeni secreti kullanacak şekilde güncelleyin.', + 'submit' => 'Secreti yenile', + 'cancel' => 'İptal', + ], + 'show' => [ + 'signing_secret' => 'İmza secreti', + 'last_sent' => 'Son gönderim :time', + 'listening_for' => 'Dinlenenler', + 'http_status' => 'HTTP durumu', + 'status_code' => ':code - :reason', + 'attempts' => 'Denemeler', + 'delivered_at' => 'Teslim edildi', + 'response_body' => 'Yanıt gövdesi', + 'response' => 'Yanıt', + 'no_response_body' => 'Yanıt gövdesi yok', + 'no_response' => 'Yanıt yok', + 'payload' => 'Mesaj payloadı', + 'empty_title' => 'Henüz webhook olayı yok', + 'empty_description' => 'Gönderiler oluşturulduğunda, zamanlandığında, zamanlaması kaldırıldığında veya yayınlandığında webhook olaylarını burada görürsünüz.', + ], + 'events' => [ + 'group_posts' => 'Gönderiler', + 'post_created' => 'Gönderi oluşturuldu', + 'post_scheduled' => 'Gönderi zamanlandı', + 'post_unscheduled' => 'Zamanlama kaldırıldı', + 'post_published' => 'Gönderi yayınlandı', + 'post_partially_published' => 'Gönderi kısmen yayınlandı', + 'post_failed' => 'Gönderi başarısız', + 'post_deleted' => 'Gönderi silindi', + ], + 'http_reasons' => [ + 'unknown' => 'Bilinmiyor', + '200' => 'OK', + '201' => 'Oluşturuldu', + '202' => 'Kabul edildi', + '204' => 'İçerik yok', + '400' => 'Hatalı istek', + '401' => 'Yetkisiz', + '403' => 'Yasak', + '404' => 'Bulunamadı', + '408' => 'İstek zaman aşımı', + '422' => 'İşlenemeyen varlık', + '429' => 'Çok fazla istek', + '500' => 'Sunucu iç hatası', + '502' => 'Geçersiz ağ geçidi', + '503' => 'Hizmet kullanılamıyor', + '504' => 'Ağ geçidi zaman aşımı', + ], + 'copied' => [ + 'id' => 'Webhook kimliği panoya kopyalandı', + 'secret' => 'İmza secreti panoya kopyalandı', + 'response' => 'Yanıt gövdesi kopyalandı', + 'payload' => 'Payload kopyalandı', + ], + 'errors' => [ + 'endpoint_not_allowed' => 'Bu endpointe izin verilmiyor.', + 'endpoint_unreachable' => 'Endpoint erişilebilir değil.', + 'endpoint_http_status' => 'Endpoint HTTP :status döndürdü.', + ], + 'flash' => [ + 'created' => 'Webhook oluşturuldu.', + 'updated' => 'Webhook güncellendi.', + 'deleted' => 'Webhook silindi.', + 'secret_rotated' => 'İmza secreti yenilendi.', + 'replayed' => 'Webhook olayı yeniden gönderildi.', + 'tested' => 'Test olayı gönderildi.', + ], + 'mail' => [ + 'paused_subject' => 'Webhook duraklatıldı: :endpoint', + 'paused_title' => 'Webhook tekrarlanan hatalardan sonra duraklatıldı', + 'paused_preview' => 'Üst üste 5 teslim hatasından sonra bir webhooku duraklattık.', + 'paused_body' => ':endpoint adresindeki webhooku üst üste 5 teslim hatasından sonra duraklattık. Endpointi gözden geçirin ve webhook ayrıntı sayfasından yeniden etkinleştirin.', + 'paused_cta' => 'Webhooku gör', + ], +]; diff --git a/lang/uk/automations.php b/lang/uk/automations.php index 4e1f8df7..f6c2f9c3 100644 --- a/lang/uk/automations.php +++ b/lang/uk/automations.php @@ -62,7 +62,6 @@ 'delay' => 'Затримка', 'condition' => 'Умова', 'publish' => 'Публікація', - 'webhook' => 'Webhook', 'end' => 'Кінець', 'fetch_rss' => 'Отримати RSS', 'http_request' => 'HTTP-запит', @@ -213,7 +212,6 @@ 'delay' => 'Затримка', 'condition' => 'Умова', 'publish' => 'Публікація', - 'webhook' => 'Webhook', 'end' => 'Кінець', 'end_summary' => 'Зупиняє автоматизацію тут', 'fetch_rss' => 'Отримати RSS', @@ -326,11 +324,6 @@ 'scheduled_offset' => 'Зміщення від тригера (хвилини)', 'offset_summary' => ':mode · +:offset хв', ], - 'webhook' => [ - 'url' => 'URL', - 'method' => 'Метод', - 'payload_template' => 'Шаблон payload (JSON)', - ], 'end' => [ 'reason' => 'Причина (необов’язково)', 'reason_placeholder' => 'напр. Відфільтровано умовою', @@ -394,10 +387,6 @@ 'graph_contains_cycle' => 'Граф автоматизації містить цикл.', 'only_failed_can_retry' => 'Повторити можна лише запуски з помилкою.', 'no_generated_post' => 'У запуску не знайдено згенерованого поста.', - 'webhook_server_error' => 'Помилка сервера webhook.', - 'webhook_request_failed' => 'Не вдалося виконати запит webhook.', - 'webhook_missing_url' => 'У вузлі webhook відсутній URL.', - 'webhook_invalid_payload_json' => 'Шаблон payload не є коректним JSON.', 'url_not_allowed' => 'URL запиту вказує на приватну або недоступну адресу і було заблоковано.', 'node_no_longer_exists' => 'Вузол :node_id більше не існує в автоматизації.', 'no_trigger_connection' => 'До вузла-тригера не підключено жодного вузла.', diff --git a/lang/uk/sidebar.php b/lang/uk/sidebar.php index 399a8962..a668212d 100644 --- a/lang/uk/sidebar.php +++ b/lang/uk/sidebar.php @@ -16,6 +16,7 @@ 'signatures' => 'Підписи', 'labels' => 'Мітки', 'assets' => 'Медіафайли', + 'webhooks' => 'Вебхуки', 'mcp' => 'MCP', ], 'language' => 'Мова: :name', diff --git a/lang/uk/webhooks.php b/lang/uk/webhooks.php new file mode 100644 index 00000000..3d6acfce --- /dev/null +++ b/lang/uk/webhooks.php @@ -0,0 +1,139 @@ + 'Webhooks', + 'description' => 'Отримуйте сповіщення в реальному часі, коли пости створюються, плануються, знімаються з плану, публікуються або завершуються помилкою.', + 'new' => 'Створити вебхук', + 'empty_title' => 'Вебхуків ще немає', + 'empty_description' => 'Створіть вебхук, щоб отримувати сповіщення про події в реальному часі.', + 'table' => [ + 'endpoint' => 'Endpoint', + 'events' => 'Слухає', + 'status' => 'Статус', + 'last_sent' => 'Остання відправка', + ], + 'events_count' => '{1} :count подія|[2,*] :count подій', + 'never' => 'Ніколи', + 'status' => [ + 'enabled' => 'Увімкнено', + 'disabled' => 'Вимкнено', + 'paused' => 'Призупинено', + ], + 'actions' => [ + 'view' => 'Переглянути деталі', + 'copy_id' => 'Скопіювати ID вебхука', + 'delete' => 'Видалити', + 'edit' => 'Редагувати endpoint', + 'enable' => 'Увімкнути endpoint', + 'disable' => 'Вимкнути endpoint', + 'rotate' => 'Змінити секрет підпису', + 'send_test' => 'Надіслати тестову подію', + 'replay' => 'Надіслати знову', + 'reveal_secret' => 'Показати секрет', + 'hide_secret' => 'Приховати секрет', + 'copy_secret' => 'Скопіювати секрет', + ], + 'create' => [ + 'title' => 'Створити вебхук', + 'description' => 'Налаштуйте endpoint, щоб отримувати сповіщення вебхука.', + 'endpoint' => 'URL endpoint', + 'endpoint_placeholder' => 'https://example.com/webhooks', + 'events' => 'Події', + 'events_placeholder' => 'Виберіть події...', + 'events_selected' => '{1} :count подію вибрано|[2,*] :count подій вибрано', + 'search_events' => 'Шукати події...', + 'no_events' => 'Подій не знайдено', + 'submit' => 'Створити вебхук', + 'cancel' => 'Скасувати', + ], + 'edit' => [ + 'title' => 'Редагувати endpoint', + 'description' => 'Оновіть URL endpoint і події для прослуховування.', + 'submit' => 'Зберегти зміни', + 'cancel' => 'Скасувати', + ], + 'delete' => [ + 'title' => 'Видалити вебхук', + 'description' => 'Видалити цей вебхук? Ви більше не отримуватимете сповіщення про події на цьому endpoint.', + 'confirm' => 'Видалити вебхук', + 'cancel' => 'Скасувати', + ], + 'rotate' => [ + 'title' => 'Змінити секрет підпису', + 'description' => 'Буде створено новий секрет підпису. Поточний секрет одразу перестане працювати. Оновіть endpoint, щоб використовувати новий секрет.', + 'submit' => 'Змінити секрет', + 'cancel' => 'Скасувати', + ], + 'show' => [ + 'signing_secret' => 'Секрет підпису', + 'last_sent' => 'Остання відправка :time', + 'listening_for' => 'Слухає', + 'http_status' => 'Статус HTTP', + 'status_code' => ':code - :reason', + 'attempts' => 'Спроби', + 'delivered_at' => 'Доставлено', + 'response_body' => 'Тіло відповіді', + 'response' => 'Відповідь', + 'no_response_body' => 'Немає тіла відповіді', + 'no_response' => 'Немає відповіді', + 'payload' => 'Payload повідомлення', + 'empty_title' => 'Подій ще немає', + 'empty_description' => 'Коли пости створюються, плануються, знімаються з плану або публікуються, події вебхука з\'являться тут.', + ], + 'events' => [ + 'group_posts' => 'Пости', + 'post_created' => 'Пост створено', + 'post_scheduled' => 'Пост заплановано', + 'post_unscheduled' => 'Планування скасовано', + 'post_published' => 'Пост опубліковано', + 'post_partially_published' => 'Пост частково опубліковано', + 'post_failed' => 'Помилка публікації', + 'post_deleted' => 'Пост видалено', + ], + 'http_reasons' => [ + 'unknown' => 'Невідомо', + '200' => 'OK', + '201' => 'Створено', + '202' => 'Прийнято', + '204' => 'Немає вмісту', + '400' => 'Неправильний запит', + '401' => 'Не авторизовано', + '403' => 'Заборонено', + '404' => 'Не знайдено', + '408' => 'Час очікування минув', + '422' => 'Необроблювана сутність', + '429' => 'Забагато запитів', + '500' => 'Внутрішня помилка сервера', + '502' => 'Помилковий шлюз', + '503' => 'Сервіс недоступний', + '504' => 'Час очікування шлюзу минув', + ], + 'copied' => [ + 'id' => 'ID вебхука скопійовано в буфер обміну', + 'secret' => 'Секрет підпису скопійовано в буфер обміну', + 'response' => 'Тіло відповіді скопійовано', + 'payload' => 'Payload скопійовано', + ], + 'errors' => [ + 'endpoint_not_allowed' => 'Цей endpoint не дозволений.', + 'endpoint_unreachable' => 'Endpoint недоступний.', + 'endpoint_http_status' => 'Endpoint повернув HTTP :status.', + ], + 'flash' => [ + 'created' => 'Вебхук створено.', + 'updated' => 'Вебхук оновлено.', + 'deleted' => 'Вебхук видалено.', + 'secret_rotated' => 'Секрет підпису змінено.', + 'replayed' => 'Подію вебхука надіслано знову.', + 'tested' => 'Тестову подію надіслано.', + ], + 'mail' => [ + 'paused_subject' => 'Вебхук призупинено: :endpoint', + 'paused_title' => 'Вебхук призупинено після повторних помилок', + 'paused_preview' => 'Ми призупинили вебхук після 5 підряд невдалих доставок.', + 'paused_body' => 'Ми призупинили вебхук на :endpoint після 5 підряд невдалих доставок. Перевірте endpoint і ввімкніть його знову на сторінці вебхука.', + 'paused_cta' => 'Відкрити вебхук', + ], +]; diff --git a/lang/zh/automations.php b/lang/zh/automations.php index e8bbb4e5..3f41f389 100644 --- a/lang/zh/automations.php +++ b/lang/zh/automations.php @@ -62,7 +62,6 @@ 'delay' => '延迟', 'condition' => '条件', 'publish' => '发布', - 'webhook' => 'Webhook', 'end' => '结束', 'fetch_rss' => '抓取 RSS', 'http_request' => 'HTTP 请求', @@ -213,7 +212,6 @@ 'delay' => '延迟', 'condition' => '条件', 'publish' => '发布', - 'webhook' => 'Webhook', 'end' => '结束', 'end_summary' => '在此停止自动化', 'fetch_rss' => '抓取 RSS', @@ -326,11 +324,6 @@ 'scheduled_offset' => '相对触发的延迟(分钟)', 'offset_summary' => ':mode · +:offset 分钟', ], - 'webhook' => [ - 'url' => 'URL', - 'method' => '方法', - 'payload_template' => '载荷模板(JSON)', - ], 'end' => [ 'reason' => '原因(可选)', 'reason_placeholder' => '例如 被条件过滤掉', @@ -394,10 +387,6 @@ 'graph_contains_cycle' => '自动化流程图中包含环路。', 'only_failed_can_retry' => '只有失败的运行才能重试。', 'no_generated_post' => '在该运行中未找到已生成的帖子。', - 'webhook_server_error' => 'Webhook 服务器错误。', - 'webhook_request_failed' => 'Webhook 请求无法完成。', - 'webhook_missing_url' => 'Webhook 节点缺少 URL。', - 'webhook_invalid_payload_json' => '载荷模板不是有效的 JSON。', 'url_not_allowed' => '请求 URL 指向私有或无法访问的地址,已被拦截。', 'node_no_longer_exists' => '节点 :node_id 已不存在于该自动化中。', 'no_trigger_connection' => '没有节点连接到触发器节点。', diff --git a/lang/zh/sidebar.php b/lang/zh/sidebar.php index 23dc2e1a..347bea9e 100644 --- a/lang/zh/sidebar.php +++ b/lang/zh/sidebar.php @@ -16,6 +16,7 @@ 'signatures' => '签名', 'labels' => '标签', 'assets' => '素材库', + 'webhooks' => 'Webhooks', 'mcp' => 'MCP', ], 'language' => '语言::name', diff --git a/lang/zh/webhooks.php b/lang/zh/webhooks.php new file mode 100644 index 00000000..d9e6cd3c --- /dev/null +++ b/lang/zh/webhooks.php @@ -0,0 +1,139 @@ + 'Webhooks', + 'description' => '在帖子被创建、排期、取消排期、发布或失败时接收实时通知。', + 'new' => '创建 webhook', + 'empty_title' => '还没有 webhook', + 'empty_description' => '创建一个 webhook,即可实时接收事件通知。', + 'table' => [ + 'endpoint' => 'Endpoint', + 'events' => '正在监听', + 'status' => '状态', + 'last_sent' => '上次发送', + ], + 'events_count' => '{1} :count 个事件|[2,*] :count 个事件', + 'never' => '从未', + 'status' => [ + 'enabled' => '已启用', + 'disabled' => '已停用', + 'paused' => '已暂停', + ], + 'actions' => [ + 'view' => '查看详情', + 'copy_id' => '复制 webhook ID', + 'delete' => '删除', + 'edit' => '编辑 endpoint', + 'enable' => '启用 endpoint', + 'disable' => '停用 endpoint', + 'rotate' => '轮换签名密钥', + 'send_test' => '发送测试事件', + 'replay' => '重新发送', + 'reveal_secret' => '显示密钥', + 'hide_secret' => '隐藏密钥', + 'copy_secret' => '复制密钥', + ], + 'create' => [ + 'title' => '创建 webhook', + 'description' => '配置一个 endpoint 以接收 webhook 通知。', + 'endpoint' => 'Endpoint URL', + 'endpoint_placeholder' => 'https://example.com/webhooks', + 'events' => '事件', + 'events_placeholder' => '选择事件...', + 'events_selected' => '{1} 已选择 :count 个事件|[2,*] 已选择 :count 个事件', + 'search_events' => '搜索事件...', + 'no_events' => '未找到事件', + 'submit' => '创建 webhook', + 'cancel' => '取消', + ], + 'edit' => [ + 'title' => '编辑 endpoint', + 'description' => '更新 endpoint URL 以及要监听的事件。', + 'submit' => '保存更改', + 'cancel' => '取消', + ], + 'delete' => [ + 'title' => '删除 webhook', + 'description' => '确定要删除此 webhook 吗?此后你将不再在此 endpoint 收到事件通知。', + 'confirm' => '删除 webhook', + 'cancel' => '取消', + ], + 'rotate' => [ + 'title' => '轮换签名密钥', + 'description' => '这将生成新的签名密钥。当前密钥会立即失效。请更新你的 endpoint 以使用新密钥。', + 'submit' => '轮换密钥', + 'cancel' => '取消', + ], + 'show' => [ + 'signing_secret' => '签名密钥', + 'last_sent' => '上次发送 :time', + 'listening_for' => '正在监听', + 'http_status' => 'HTTP 状态', + 'status_code' => ':code - :reason', + 'attempts' => '尝试次数', + 'delivered_at' => '送达时间', + 'response_body' => '响应正文', + 'response' => '响应', + 'no_response_body' => '无响应正文', + 'no_response' => '无响应', + 'payload' => '消息 payload', + 'empty_title' => '还没有 webhook 事件', + 'empty_description' => '帖子被创建、排期、取消排期或发布后,webhook 事件会显示在这里。', + ], + 'events' => [ + 'group_posts' => '帖子', + 'post_created' => '帖子已创建', + 'post_scheduled' => '帖子已排期', + 'post_unscheduled' => '已取消排期', + 'post_published' => '帖子已发布', + 'post_partially_published' => '帖子部分发布', + 'post_failed' => '帖子失败', + 'post_deleted' => '帖子已删除', + ], + 'http_reasons' => [ + 'unknown' => '未知', + '200' => 'OK', + '201' => '已创建', + '202' => '已接受', + '204' => '无内容', + '400' => '错误请求', + '401' => '未授权', + '403' => '禁止访问', + '404' => '未找到', + '408' => '请求超时', + '422' => '无法处理的实体', + '429' => '请求过多', + '500' => '服务器内部错误', + '502' => '错误网关', + '503' => '服务不可用', + '504' => '网关超时', + ], + 'copied' => [ + 'id' => '已将 webhook ID 复制到剪贴板', + 'secret' => '已将签名密钥复制到剪贴板', + 'response' => '已复制响应正文', + 'payload' => '已复制 payload', + ], + 'errors' => [ + 'endpoint_not_allowed' => '不允许使用此 endpoint。', + 'endpoint_unreachable' => '无法访问该 endpoint。', + 'endpoint_http_status' => '该 endpoint 返回了 HTTP :status。', + ], + 'flash' => [ + 'created' => '已创建 webhook。', + 'updated' => '已更新 webhook。', + 'deleted' => '已删除 webhook。', + 'secret_rotated' => '已轮换签名密钥。', + 'replayed' => '已重新发送 webhook 事件。', + 'tested' => '已发送测试事件。', + ], + 'mail' => [ + 'paused_subject' => 'Webhook 已暂停::endpoint', + 'paused_title' => 'Webhook 因连续失败已暂停', + 'paused_preview' => '连续 5 次投递失败后,我们暂停了一个 webhook。', + 'paused_body' => '连续 5 次投递失败后,我们暂停了 :endpoint 上的 webhook。请检查该 endpoint,并在 webhook 详情页重新启用。', + 'paused_cta' => '查看 webhook', + ], +]; diff --git a/maizzle/templates/webhook-paused.html b/maizzle/templates/webhook-paused.html new file mode 100644 index 00000000..e190680d --- /dev/null +++ b/maizzle/templates/webhook-paused.html @@ -0,0 +1,34 @@ + +
+ + + + +
+ + + + + + +
+

+ @{{ $title }} +

+ +

+ @{{ $body }} +

+ + + +
+ + @{{ $buttonText }} → + +
+
+ +
+
+
diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index bdc0e120..2ff7ce99 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -19,6 +19,7 @@ import { IconPlugConnected, IconSelector, IconTag, + IconWebhook, } from '@tabler/icons-vue'; import { trans } from 'laravel-vue-i18n'; import { computed } from 'vue'; @@ -57,6 +58,7 @@ import { portal } from '@/routes/app/billing'; import { index as labels } from '@/routes/app/labels'; import { index as mcp } from '@/routes/app/mcp'; import { index as signatures } from '@/routes/app/signatures'; +import { index as webhooks } from '@/routes/app/webhooks'; import type { NavItem, User } from '@/types'; interface Workspace { @@ -80,6 +82,7 @@ const subscriptionPastDue = computed(() => const { canCreatePost, canManageAccounts, + canManageWebhooks, canManageAutomations, canCreateWorkspace, } = useWorkspaceRole(); @@ -165,6 +168,15 @@ const workspaceNavItems = computed(() => [ }, ] : []), + ...(canManageWebhooks.value + ? [ + { + title: trans('sidebar.workspace.webhooks'), + href: webhooks.url(), + icon: IconWebhook, + }, + ] + : []), { title: trans('sidebar.workspace.mcp'), href: mcp.url(), diff --git a/resources/js/components/JsonViewer.vue b/resources/js/components/JsonViewer.vue index f7906c40..8c470eb5 100644 --- a/resources/js/components/JsonViewer.vue +++ b/resources/js/components/JsonViewer.vue @@ -4,6 +4,12 @@ import hljs from 'highlight.js/lib/core'; import jsonLang from 'highlight.js/lib/languages/json'; import { computed } from 'vue'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; import { copyToClipboard } from '@/lib/utils'; hljs.registerLanguage('json', jsonLang); @@ -26,17 +32,26 @@ const highlighted = computed(() => { diff --git a/resources/js/components/Toast.vue b/resources/js/components/Toast.vue index a85cee5b..a9a93744 100644 --- a/resources/js/components/Toast.vue +++ b/resources/js/components/Toast.vue @@ -1,55 +1,62 @@ diff --git a/resources/js/components/automations/BuildPanel.vue b/resources/js/components/automations/BuildPanel.vue index 18c303d2..92098ffa 100644 --- a/resources/js/components/automations/BuildPanel.vue +++ b/resources/js/components/automations/BuildPanel.vue @@ -7,7 +7,6 @@ import { IconRss, IconSend, IconSparkles, - IconWebhook, IconWorld, } from '@tabler/icons-vue'; import { trans } from 'laravel-vue-i18n'; @@ -41,7 +40,6 @@ const categories = computed(() => [ title: trans('automations.categories.output'), nodes: [ { type: NodeType.Publish, label: trans('automations.nodes.publish'), icon: IconSend, accent: 'emerald' }, - { type: NodeType.Webhook, label: trans('automations.nodes.webhook'), icon: IconWebhook, accent: 'slate' }, ], }, ]); diff --git a/resources/js/components/automations/config-validation.ts b/resources/js/components/automations/config-validation.ts deleted file mode 100644 index 814bbf93..00000000 --- a/resources/js/components/automations/config-validation.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { trans } from 'laravel-vue-i18n'; - -import { NodeType } from '@/types/automation/node-type'; - -interface WorkflowNode { - type?: string; - data?: Record | null; -} - -/** - * Mirrors the backend WebhookNodeValidator: a webhook payload template must be - * valid JSON because the runtime parses it before resolving `{{ }}` placeholders. - * An empty or literal-`null` template means "no body" and is valid. - */ -export const isPayloadTemplateValid = (template: string): boolean => { - const trimmed = template.trim(); - - if (trimmed === '' || trimmed === 'null') { - return true; - } - - try { - JSON.parse(trimmed); - return true; - } catch { - return false; - } -}; - -/** - * First node-config issue that would block a run, or null when every node is - * runnable. The frontend gate intentionally covers only the Webhook node — the - * Generate node has its own inline compliance UI, and the backend - * AutomationConfigValidator remains the safety net for everything. - */ -export const firstConfigIssue = (nodes: WorkflowNode[]): string | null => { - for (const node of nodes) { - if (node.type === NodeType.Webhook && !isPayloadTemplateValid(String(node.data?.payload_template ?? ''))) { - return trans('automations.errors.webhook_invalid_payload_json'); - } - } - - return null; -}; diff --git a/resources/js/components/automations/config/WebhookNodeConfig.vue b/resources/js/components/automations/config/WebhookNodeConfig.vue deleted file mode 100644 index cf26c693..00000000 --- a/resources/js/components/automations/config/WebhookNodeConfig.vue +++ /dev/null @@ -1,84 +0,0 @@ - - - diff --git a/resources/js/components/automations/nodes/WebhookNode.vue b/resources/js/components/automations/nodes/WebhookNode.vue deleted file mode 100644 index 2bd36bcc..00000000 --- a/resources/js/components/automations/nodes/WebhookNode.vue +++ /dev/null @@ -1,48 +0,0 @@ - - - diff --git a/resources/js/components/webhook/CreateWebhookDialog.vue b/resources/js/components/webhook/CreateWebhookDialog.vue new file mode 100644 index 00000000..c716d35f --- /dev/null +++ b/resources/js/components/webhook/CreateWebhookDialog.vue @@ -0,0 +1,80 @@ + + + diff --git a/resources/js/components/webhook/EditWebhookDialog.vue b/resources/js/components/webhook/EditWebhookDialog.vue new file mode 100644 index 00000000..af6fe7c7 --- /dev/null +++ b/resources/js/components/webhook/EditWebhookDialog.vue @@ -0,0 +1,91 @@ + + + diff --git a/resources/js/components/webhook/RotateSecretDialog.vue b/resources/js/components/webhook/RotateSecretDialog.vue new file mode 100644 index 00000000..2377ebf2 --- /dev/null +++ b/resources/js/components/webhook/RotateSecretDialog.vue @@ -0,0 +1,62 @@ + + + diff --git a/resources/js/components/webhook/WebhookActionsMenu.vue b/resources/js/components/webhook/WebhookActionsMenu.vue new file mode 100644 index 00000000..1cce3ff0 --- /dev/null +++ b/resources/js/components/webhook/WebhookActionsMenu.vue @@ -0,0 +1,127 @@ + + + diff --git a/resources/js/components/webhook/WebhookFormFields.vue b/resources/js/components/webhook/WebhookFormFields.vue new file mode 100644 index 00000000..33dcba13 --- /dev/null +++ b/resources/js/components/webhook/WebhookFormFields.vue @@ -0,0 +1,102 @@ + + + diff --git a/resources/js/components/webhook/WebhookLogDetail.vue b/resources/js/components/webhook/WebhookLogDetail.vue new file mode 100644 index 00000000..725da08e --- /dev/null +++ b/resources/js/components/webhook/WebhookLogDetail.vue @@ -0,0 +1,139 @@ + + + diff --git a/resources/js/components/webhook/WebhookLogList.vue b/resources/js/components/webhook/WebhookLogList.vue new file mode 100644 index 00000000..d81bea62 --- /dev/null +++ b/resources/js/components/webhook/WebhookLogList.vue @@ -0,0 +1,64 @@ + + + diff --git a/resources/js/components/webhook/WebhookLogViewer.vue b/resources/js/components/webhook/WebhookLogViewer.vue new file mode 100644 index 00000000..5a2b6c44 --- /dev/null +++ b/resources/js/components/webhook/WebhookLogViewer.vue @@ -0,0 +1,48 @@ + + + diff --git a/resources/js/components/webhook/WebhookOverview.vue b/resources/js/components/webhook/WebhookOverview.vue new file mode 100644 index 00000000..221c4607 --- /dev/null +++ b/resources/js/components/webhook/WebhookOverview.vue @@ -0,0 +1,106 @@ + + + diff --git a/resources/js/components/webhook/WebhookShowHeader.vue b/resources/js/components/webhook/WebhookShowHeader.vue new file mode 100644 index 00000000..792c3480 --- /dev/null +++ b/resources/js/components/webhook/WebhookShowHeader.vue @@ -0,0 +1,68 @@ + + + diff --git a/resources/js/components/webhook/webhook-events.ts b/resources/js/components/webhook/webhook-events.ts new file mode 100644 index 00000000..4d2d6a19 --- /dev/null +++ b/resources/js/components/webhook/webhook-events.ts @@ -0,0 +1,21 @@ +import { trans } from 'laravel-vue-i18n'; + +export const webhookEventGroups = [ + { + labelKey: 'webhooks.events.group_posts', + events: [ + 'post.created', + 'post.scheduled', + 'post.unscheduled', + 'post.published', + 'post.partially_published', + 'post.failed', + 'post.deleted', + ], + }, +]; + +export const webhookEventLabelKey = (event: string): string => + `webhooks.events.${event.replaceAll('.', '_')}`; + +export const webhookEventLabel = (event: string): string => trans(webhookEventLabelKey(event)); diff --git a/resources/js/composables/echo/useWebhookEcho.ts b/resources/js/composables/echo/useWebhookEcho.ts new file mode 100644 index 00000000..bb29a08f --- /dev/null +++ b/resources/js/composables/echo/useWebhookEcho.ts @@ -0,0 +1,9 @@ +import { useEcho } from '@laravel/echo-vue'; + +export const useWebhookEcho = ( + webhookId: string, + event: string | string[], + callback: (payload: T) => void, +) => { + return useEcho(`webhook.${webhookId}.logs`, event, callback); +}; diff --git a/resources/js/composables/useWebhookLogs.ts b/resources/js/composables/useWebhookLogs.ts new file mode 100644 index 00000000..8095e470 --- /dev/null +++ b/resources/js/composables/useWebhookLogs.ts @@ -0,0 +1,110 @@ +import { ref, toValue, watch, type MaybeRefOrGetter } from 'vue'; + +import { useWebhookEcho } from '@/composables/echo/useWebhookEcho'; +import dayjs from '@/dayjs'; +import type { WebhookLog } from '@/types/webhook'; + +type LiveFields = Pick< + WebhookLog, + 'response_status' | 'response_body' | 'delivered_at' | 'failed_at' | 'attempts' +>; + +const liveFields = (log: WebhookLog): LiveFields => ({ + response_status: log.response_status, + response_body: log.response_body, + delivered_at: log.delivered_at, + failed_at: log.failed_at, + attempts: log.attempts, +}); + +const compareNewestFirst = (left: WebhookLog, right: WebhookLog): number => + dayjs(right.created_at).valueOf() - dayjs(left.created_at).valueOf(); + +const mergeIncomingLog = (incoming: WebhookLog, local?: WebhookLog): WebhookLog => { + if (!local) { + return incoming; + } + + const incomingDelivered = Boolean(incoming.delivered_at); + const localDelivered = Boolean(local.delivered_at); + const incomingFailed = Boolean(incoming.failed_at); + const localFailed = Boolean(local.failed_at); + + if (incomingDelivered && !localDelivered) { + return incoming; + } + + if ( + (localDelivered && !incomingDelivered) + || local.attempts > incoming.attempts + || (localFailed && !incomingFailed && !incomingDelivered) + ) { + return { ...incoming, ...liveFields(local) }; + } + + return incoming; +}; + +const syncLogs = (incoming: WebhookLog[], local: WebhookLog[]): WebhookLog[] => { + const localById = new Map(local.map((log) => [log.id, log])); + const incomingIds = new Set(incoming.map((log) => log.id)); + const merged = incoming.map((log) => mergeIncomingLog(log, localById.get(log.id))); + const echoOnly = local.filter((log) => !incomingIds.has(log.id)); + + return [...echoOnly, ...merged].sort(compareNewestFirst); +}; + +export const useWebhookLogs = ( + webhookId: MaybeRefOrGetter, + incomingLogs: MaybeRefOrGetter, +) => { + const newLogIds = ref([]); + const liveLogs = ref([...toValue(incomingLogs)]); + const selectedLog = ref(liveLogs.value[0] ?? null); + + const applyLogUpdate = (incoming: WebhookLog): void => { + const existing = liveLogs.value.find((log) => log.id === incoming.id); + const next = existing ? { ...existing, ...liveFields(incoming) } : incoming; + + liveLogs.value = existing + ? liveLogs.value.map((log) => (log.id === next.id ? next : log)) + : [next, ...liveLogs.value].sort(compareNewestFirst); + + if (!existing && !newLogIds.value.includes(next.id)) { + newLogIds.value = [...newLogIds.value, next.id]; + } + + if (!selectedLog.value || selectedLog.value.id === next.id) { + selectedLog.value = next; + } + }; + + useWebhookEcho(toValue(webhookId), '.webhook.log.updated', applyLogUpdate); + + watch( + () => toValue(incomingLogs), + (incoming) => { + liveLogs.value = syncLogs(incoming, liveLogs.value); + + const selectedId = selectedLog.value?.id; + const stillSelected = selectedId + ? liveLogs.value.find((log) => log.id === selectedId) + : undefined; + + selectedLog.value = stillSelected ?? liveLogs.value[0] ?? null; + }, + { deep: true }, + ); + + const selectLog = (log: WebhookLog): void => { + selectedLog.value = log; + newLogIds.value = newLogIds.value.filter((id) => id !== log.id); + }; + + return { + liveLogs, + selectedLog, + newLogIds, + selectLog, + }; +}; diff --git a/resources/js/composables/useWorkspaceRole.ts b/resources/js/composables/useWorkspaceRole.ts index 52ae869e..7f9bc486 100644 --- a/resources/js/composables/useWorkspaceRole.ts +++ b/resources/js/composables/useWorkspaceRole.ts @@ -31,6 +31,7 @@ export const useWorkspaceRole = () => { canCreatePost: isMemberOrAbove, canManageAutomations: isMemberOrAbove, canManageAccounts: isAdminOrAbove, + canManageWebhooks: isAdminOrAbove, canManageTeam: isAdminOrAbove, canManageWorkspace: isAdminOrAbove, canManageBilling: isOwner, diff --git a/resources/js/pages/automations/Form.vue b/resources/js/pages/automations/Form.vue index 54390b71..d8d786de 100644 --- a/resources/js/pages/automations/Form.vue +++ b/resources/js/pages/automations/Form.vue @@ -33,8 +33,6 @@ import GenerateNodeConfig from '@/components/automations/config/GenerateNodeConf import HttpRequestNodeConfig from '@/components/automations/config/HttpRequestNodeConfig.vue'; import PublishNodeConfig from '@/components/automations/config/PublishNodeConfig.vue'; import TriggerNodeConfig from '@/components/automations/config/TriggerNodeConfig.vue'; -import WebhookNodeConfig from '@/components/automations/config/WebhookNodeConfig.vue'; -import { firstConfigIssue } from '@/components/automations/config-validation'; import EditorSidebar from '@/components/automations/EditorSidebar.vue'; import ConditionNode from '@/components/automations/nodes/ConditionNode.vue'; import DelayNode from '@/components/automations/nodes/DelayNode.vue'; @@ -44,7 +42,6 @@ import GenerateNode from '@/components/automations/nodes/GenerateNode.vue'; import HttpRequestNode from '@/components/automations/nodes/HttpRequestNode.vue'; import PublishNode from '@/components/automations/nodes/PublishNode.vue'; import TriggerNode from '@/components/automations/nodes/TriggerNode.vue'; -import WebhookNode from '@/components/automations/nodes/WebhookNode.vue'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { AddEdgeCommand } from '@/composables/history/commands/AddEdgeCommand'; @@ -78,7 +75,6 @@ const nodeTypes = { [NodeType.Delay]: markRaw(DelayNode), [NodeType.Condition]: markRaw(ConditionNode), [NodeType.Publish]: markRaw(PublishNode), - [NodeType.Webhook]: markRaw(WebhookNode), [NodeType.End]: markRaw(EndNode), [NodeType.FetchRss]: markRaw(FetchRssNode), [NodeType.HttpRequest]: markRaw(HttpRequestNode), @@ -90,7 +86,6 @@ const configByType: Record = { [NodeType.Delay]: DelayNodeConfig, [NodeType.Condition]: ConditionNodeConfig, [NodeType.Publish]: PublishNodeConfig, - [NodeType.Webhook]: WebhookNodeConfig, [NodeType.End]: EndNodeConfig, [NodeType.FetchRss]: FetchRssNodeConfig, [NodeType.HttpRequest]: HttpRequestNodeConfig, @@ -111,8 +106,6 @@ const selectedNodeId = ref(null); const selectedEdgeId = ref(null); const variables = ref(props.automation.variables ?? []); -const configIssue = computed(() => firstConfigIssue(nodes.value)); - watch( () => [props.automation.nodes, props.automation.connections] as const, ([newNodes, newEdges]) => { @@ -280,7 +273,6 @@ const defaultConfigFor = (type: string): Record => { case NodeType.Delay: return { duration: 1, unit: DelayUnit.Hours }; case NodeType.Condition: return { field: '', operator: ConditionOperator.Contains, value: '' }; case NodeType.Publish: return { mode: PublishMode.Now, scheduled_offset: 60 }; - case NodeType.Webhook: return { url: '', method: HttpMethod.Post, headers: {}, payload_template: '{}' }; case NodeType.End: return { reason: '' }; case NodeType.FetchRss: return { feed_url: '' }; case NodeType.HttpRequest: return { @@ -525,7 +517,6 @@ const defaultEdgeOptions = { v-model:variables="variables" :automation-id="automation.id" :before-run="save" - :config-issue="configIssue" :editing="!!selectedNode" :node-title="selectedNode ? $t(`automations.nodes.${selectedNode.type}`) : ''" :deletable="selectedNode?.type !== NodeType.Trigger" diff --git a/resources/js/pages/automations/Settings.vue b/resources/js/pages/automations/Settings.vue index b422bcea..47bc9965 100644 --- a/resources/js/pages/automations/Settings.vue +++ b/resources/js/pages/automations/Settings.vue @@ -1,12 +1,11 @@ + + diff --git a/resources/js/pages/webhooks/Show.vue b/resources/js/pages/webhooks/Show.vue new file mode 100644 index 00000000..551765ac --- /dev/null +++ b/resources/js/pages/webhooks/Show.vue @@ -0,0 +1,71 @@ + + + diff --git a/resources/js/types/automation/http-method.ts b/resources/js/types/automation/http-method.ts index fa61e59b..1b95a982 100644 --- a/resources/js/types/automation/http-method.ts +++ b/resources/js/types/automation/http-method.ts @@ -1,5 +1,5 @@ /** - * HTTP verbs for the HTTP Request and Webhook nodes. Mirrors the backend + * HTTP verbs for the HTTP Request node. Mirrors the backend * App\Enums\Automation\HttpMethod enum — these values feed the node config * selects and must match the validation rules server-side. */ diff --git a/resources/js/types/automation/node-type.ts b/resources/js/types/automation/node-type.ts index 8eaaeba8..ebf6088a 100644 --- a/resources/js/types/automation/node-type.ts +++ b/resources/js/types/automation/node-type.ts @@ -4,7 +4,6 @@ export const NodeType = { Delay: 'delay', Condition: 'condition', Publish: 'publish', - Webhook: 'webhook', End: 'end', FetchRss: 'fetch_rss', HttpRequest: 'http_request', diff --git a/resources/js/types/webhook-status.ts b/resources/js/types/webhook-status.ts new file mode 100644 index 00000000..d33c2fa7 --- /dev/null +++ b/resources/js/types/webhook-status.ts @@ -0,0 +1,20 @@ +export const WebhookStatus = { + Enabled: 'enabled', + Disabled: 'disabled', + Paused: 'paused', +} as const; + +export type WebhookStatusValue = + (typeof WebhookStatus)[keyof typeof WebhookStatus]; + +type WebhookStatusBadgeVariant = 'default' | 'secondary' | 'warning'; + +const webhookStatusVariants = { + [WebhookStatus.Enabled]: 'default', + [WebhookStatus.Disabled]: 'secondary', + [WebhookStatus.Paused]: 'warning', +} as const satisfies Record; + +export const webhookStatusVariant = ( + status: WebhookStatusValue, +): WebhookStatusBadgeVariant => webhookStatusVariants[status]; diff --git a/resources/js/types/webhook.ts b/resources/js/types/webhook.ts new file mode 100644 index 00000000..676ccdd3 --- /dev/null +++ b/resources/js/types/webhook.ts @@ -0,0 +1,25 @@ +import type { WebhookStatusValue } from '@/types/webhook-status'; + +export interface Webhook { + id: string; + endpoint: string; + events: string[]; + status: WebhookStatusValue; + last_sent_at: string | null; +} + +export interface WebhookWithSecret extends Webhook { + signing_secret: string; +} + +export interface WebhookLog { + id: string; + event_type: string; + payload: Record | null; + response_status: number | null; + response_body: string | null; + delivered_at: string | null; + failed_at: string | null; + attempts: number; + created_at: string; +} diff --git a/resources/views/mail/webhook-paused.blade.php b/resources/views/mail/webhook-paused.blade.php new file mode 100644 index 00000000..5582cc31 --- /dev/null +++ b/resources/views/mail/webhook-paused.blade.php @@ -0,0 +1,140 @@ + + + + + + + + + + + @if(isset($title)) + {{ $title }} + @endif + + + + + + + @if(isset($previewText)) +
+ {{ $previewText }} +  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏ +
+ @endif +
+
+ + + + + + + +
+
+ + Trypost + +
+ + + + +
+

+ {{ $title }} +

+

+ {{ $body }} +

+
+ +
+
+

+ Open-source social media scheduling tool +

+

+ + Manage notifications + +

+ + + + + + + + +
+ + GitHub + + + + X + + + + YouTube + + + + Discord + + + + Instagram + +
+

+ © {{ date('Y') }} TryPost.it +

+
+
+
+ + \ No newline at end of file diff --git a/routes/app.php b/routes/app.php index e1612c49..8c85de83 100644 --- a/routes/app.php +++ b/routes/app.php @@ -27,6 +27,7 @@ use App\Http\Controllers\App\Settings\SettingsController; use App\Http\Controllers\App\Settings\UsageController; use App\Http\Controllers\App\UnsplashController; +use App\Http\Controllers\App\WebhookController; use App\Http\Controllers\App\WelcomeController; use App\Http\Controllers\App\WorkspaceController; use App\Http\Controllers\App\WorkspaceInviteController; @@ -274,6 +275,16 @@ Route::get('settings/workspace/mcp', [McpSettingsController::class, 'index'])->name('app.mcp.index'); Route::delete('settings/workspace/mcp/{client}', [McpSettingsController::class, 'disconnect'])->name('app.mcp.disconnect'); + // Webhooks + Route::get('webhooks', [WebhookController::class, 'index'])->name('app.webhooks.index'); + Route::post('webhooks', [WebhookController::class, 'store'])->name('app.webhooks.store'); + Route::get('webhooks/{webhook}', [WebhookController::class, 'show'])->name('app.webhooks.show'); + Route::put('webhooks/{webhook}', [WebhookController::class, 'update'])->name('app.webhooks.update'); + Route::post('webhooks/{webhook}/send-test', [WebhookController::class, 'sendTest'])->name('app.webhooks.send-test'); + Route::post('webhooks/{webhook}/rotate-secret', [WebhookController::class, 'rotateSecret'])->name('app.webhooks.rotate-secret'); + Route::post('webhooks/{webhook}/logs/{webhookLog}/replay', [WebhookController::class, 'replay'])->name('app.webhooks.replay'); + Route::delete('webhooks/{webhook}', [WebhookController::class, 'destroy'])->name('app.webhooks.destroy'); + // Account Settings Route::get('settings/account', [AccountController::class, 'edit'])->name('app.account.edit'); Route::put('settings/account', [AccountController::class, 'update'])->name('app.account.update'); diff --git a/routes/channels.php b/routes/channels.php index 22f9d664..9a78090f 100644 --- a/routes/channels.php +++ b/routes/channels.php @@ -7,6 +7,7 @@ use App\Broadcasting\UserAiCreationChannel; use App\Broadcasting\UserAiGenerationChannel; use App\Broadcasting\UserAiMediaRegenerationChannel; +use App\Broadcasting\WebhookLogChannel; use App\Broadcasting\WorkspaceChannel; use App\Broadcasting\WorkspaceUserChannel; use Illuminate\Support\Facades\Broadcast; @@ -15,6 +16,8 @@ Broadcast::channel('automation.{automation}', AutomationChannel::class); +Broadcast::channel('webhook.{webhook}.logs', WebhookLogChannel::class); + Broadcast::channel('workspace.{workspace}', WorkspaceChannel::class); Broadcast::channel('workspace.{workspace}.user.{owner}', WorkspaceUserChannel::class); diff --git a/routes/console.php b/routes/console.php index 28d1ca1d..293b3f91 100644 --- a/routes/console.php +++ b/routes/console.php @@ -9,6 +9,7 @@ use App\Console\Commands\CheckSocialConnections; use App\Console\Commands\CheckUpcomingPostConnections; use App\Console\Commands\ProcessScheduledPosts; +use App\Console\Commands\PruneWebhookLogs; use App\Console\Commands\RecoverStuckPosts; use App\Console\Commands\RefreshExpiringTokens; use Illuminate\Support\Facades\Schedule; @@ -22,3 +23,4 @@ Schedule::command(ProcessAutomationDelays::class)->everyMinute()->withoutOverlapping()->onOneServer(); Schedule::command(RecoverStuckAutomationRuns::class)->everyFiveMinutes()->withoutOverlapping()->onOneServer(); Schedule::command(PruneDryRunAutomationRuns::class)->everyTenMinutes()->withoutOverlapping()->onOneServer(); +Schedule::command(PruneWebhookLogs::class)->daily()->withoutOverlapping()->onOneServer(); diff --git a/tests/Feature/App/WebhookTest.php b/tests/Feature/App/WebhookTest.php new file mode 100644 index 00000000..1943e9d5 --- /dev/null +++ b/tests/Feature/App/WebhookTest.php @@ -0,0 +1,674 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->user->update(['current_workspace_id' => $this->workspace->id]); + + $mock = Mockery::mock(WebhookService::class); + $mock->shouldReceive('assertEndpointAllowed')->andReturnNull(); + $mock->shouldReceive('ping')->andReturnNull(); + $this->app->instance(WebhookService::class, $mock); +}); + +test('guests are redirected to the login page', function () { + $this->get(route('app.webhooks.index')) + ->assertRedirect(route('login')); +}); + +test('authenticated users can view webhooks', function () { + $this->actingAs($this->user) + ->get(route('app.webhooks.index')) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('webhooks/Index') + ->has('webhooks') + ); +}); + +test('webhook index hides the signing secret', function () { + Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'signing_secret' => 'whsec_hidden', + ]); + + $this->actingAs($this->user) + ->get(route('app.webhooks.index')) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->has('webhooks', 1, fn ($webhook) => $webhook + ->missing('signing_secret') + ->etc() + ) + ); +}); + +test('authenticated users can create a webhook', function () { + $this->actingAs($this->user) + ->post(route('app.webhooks.store'), [ + 'endpoint' => 'https://example.com/webhooks', + 'events' => [EventType::PostPublished->value, EventType::PostFailed->value], + ]) + ->assertRedirect(); + + $this->assertDatabaseHas('webhooks', [ + 'workspace_id' => $this->workspace->id, + 'endpoint' => 'https://example.com/webhooks', + ]); +}); + +test('generateSigningSecret prefixes a 32 character random string', function () { + $secret = Webhook::generateSigningSecret(); + + expect($secret) + ->toStartWith('whsec_') + ->and(strlen($secret))->toBe(38) + ->and(Webhook::generateSigningSecret())->not->toBe($secret); +}); + +test('webhook signing_secret is generated with whsec_ prefix', function () { + $this->actingAs($this->user) + ->post(route('app.webhooks.store'), [ + 'endpoint' => 'https://example.com/webhooks', + 'events' => [EventType::PostPublished->value], + ]); + + $webhook = Webhook::query()->where('workspace_id', $this->workspace->id)->first(); + + expect($webhook->signing_secret)->toStartWith('whsec_'); +}); + +test('webhook signing secret is encrypted at rest', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'signing_secret' => 'whsec_test123', + ]); + + $raw = DB::table('webhooks')->where('id', $webhook->id)->value('signing_secret'); + + expect($raw)->not->toStartWith('whsec_'); + expect($webhook->signing_secret)->toBe('whsec_test123'); +}); + +test('webhook creation does not ping the endpoint', function () { + $mock = Mockery::mock(WebhookService::class); + $mock->shouldReceive('assertEndpointAllowed')->once(); + $mock->shouldNotReceive('ping'); + $this->app->instance(WebhookService::class, $mock); + + $this->actingAs($this->user) + ->post(route('app.webhooks.store'), [ + 'endpoint' => 'https://example.com/webhooks', + 'events' => [EventType::PostPublished->value], + ]) + ->assertRedirect(); + + $this->assertDatabaseHas('webhooks', [ + 'endpoint' => 'https://example.com/webhooks', + ]); +}); + +test('webhook creation fails when the endpoint is not allowed', function () { + $failingMock = Mockery::mock(WebhookService::class); + $failingMock->shouldReceive('assertEndpointAllowed') + ->andThrow(new RuntimeException(__('webhooks.errors.endpoint_not_allowed'))); + $this->app->instance(WebhookService::class, $failingMock); + + $this->actingAs($this->user) + ->post(route('app.webhooks.store'), [ + 'endpoint' => 'http://127.0.0.1/webhooks', + 'events' => [EventType::PostPublished->value], + ]) + ->assertRedirect() + ->assertSessionHasErrors('endpoint'); + + $this->assertDatabaseMissing('webhooks', [ + 'endpoint' => 'http://127.0.0.1/webhooks', + ]); +}); + +test('re-enabling a webhook resets consecutive failures', function (string $from) { + $webhook = $from === 'paused' + ? Webhook::factory()->paused()->create([ + 'workspace_id' => $this->workspace->id, + ]) + : Webhook::factory()->disabled()->create([ + 'workspace_id' => $this->workspace->id, + 'consecutive_failures' => 4, + ]); + + $this->actingAs($this->user) + ->put(route('app.webhooks.update', $webhook), [ + 'status' => 'enabled', + ]) + ->assertRedirect(); + + $webhook->refresh(); + expect($webhook->status->value)->toBe('enabled'); + expect($webhook->consecutive_failures)->toBe(0); + expect($webhook->paused_at)->toBeNull(); +})->with(['paused', 'disabled']); + +test('webhook endpoint is required', function () { + $this->actingAs($this->user) + ->post(route('app.webhooks.store'), [ + 'events' => [EventType::PostPublished->value], + ]) + ->assertSessionHasErrors('endpoint'); + + expect(session('errors')->first('endpoint')) + ->toContain(__('webhooks.create.endpoint')); +}); + +test('webhook endpoint must be a valid url', function () { + $this->actingAs($this->user) + ->post(route('app.webhooks.store'), [ + 'endpoint' => 'not-a-url', + 'events' => [EventType::PostPublished->value], + ]) + ->assertSessionHasErrors('endpoint'); +}); + +test('webhook events are required', function () { + $this->actingAs($this->user) + ->post(route('app.webhooks.store'), [ + 'endpoint' => 'https://example.com/webhooks', + 'events' => [], + ]) + ->assertSessionHasErrors('events'); +}); + +test('webhook rejects wildcard events', function () { + $this->actingAs($this->user) + ->post(route('app.webhooks.store'), [ + 'endpoint' => 'https://example.com/webhooks', + 'events' => ['*'], + ]) + ->assertSessionHasErrors('events.0'); +}); + +test('webhook accepts the post.unscheduled event', function () { + $this->actingAs($this->user) + ->post(route('app.webhooks.store'), [ + 'endpoint' => 'https://example.com/webhooks', + 'events' => [EventType::PostUnscheduled->value], + ]) + ->assertRedirect(); + + $webhook = Webhook::query()->where('workspace_id', $this->workspace->id)->first(); + + expect($webhook->events)->toEqual([EventType::PostUnscheduled->value]); +}); + +test('webhook rejects invalid event names', function () { + $this->actingAs($this->user) + ->post(route('app.webhooks.store'), [ + 'endpoint' => 'https://example.com/webhooks', + 'events' => ['foo.bar'], + ]) + ->assertSessionHasErrors('events.0'); +}); + +test('authenticated users can view a webhook with signing_secret exposed', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'signing_secret' => 'whsec_test123', + ]); + + $this->actingAs($this->user) + ->get(route('app.webhooks.show', $webhook)) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('webhooks/Show') + ->has('webhook') + ->has('logs') + ->where('webhook.signing_secret', 'whsec_test123') + ); +}); + +test('authenticated users can update a webhook endpoint', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $this->actingAs($this->user) + ->put(route('app.webhooks.update', $webhook), [ + 'endpoint' => 'https://updated.com/hook', + ]) + ->assertRedirect() + ->assertSessionHas('flash.banner', __('webhooks.flash.updated')); + + $this->assertDatabaseHas('webhooks', [ + 'id' => $webhook->id, + 'endpoint' => 'https://updated.com/hook', + ]); +}); + +test('updating a webhook endpoint does not ping the new url', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'endpoint' => 'https://old.example.com/hook', + ]); + + $mock = Mockery::mock(WebhookService::class); + $mock->shouldReceive('assertEndpointAllowed')->once()->with('https://updated.com/hook'); + $mock->shouldNotReceive('ping'); + $this->app->instance(WebhookService::class, $mock); + + $this->actingAs($this->user) + ->put(route('app.webhooks.update', $webhook), [ + 'endpoint' => 'https://updated.com/hook', + ]) + ->assertRedirect(); +}); + +test('updating a webhook endpoint fails when the endpoint is not allowed', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'endpoint' => 'https://old.example.com/hook', + ]); + + $failingMock = Mockery::mock(WebhookService::class); + $failingMock->shouldReceive('assertEndpointAllowed') + ->andThrow(new RuntimeException(__('webhooks.errors.endpoint_not_allowed'))); + $this->app->instance(WebhookService::class, $failingMock); + + $this->actingAs($this->user) + ->put(route('app.webhooks.update', $webhook), [ + 'endpoint' => 'http://127.0.0.1/webhooks', + ]) + ->assertRedirect() + ->assertSessionHasErrors('endpoint'); + + $webhook->refresh(); + + expect($webhook->endpoint)->toBe('https://old.example.com/hook'); +}); + +test('updating a webhook without changing the endpoint does not ping', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'endpoint' => 'https://same.example.com/hook', + ]); + + $mock = Mockery::mock(WebhookService::class); + $mock->shouldNotReceive('assertEndpointAllowed'); + $mock->shouldNotReceive('ping'); + $this->app->instance(WebhookService::class, $mock); + + $this->actingAs($this->user) + ->put(route('app.webhooks.update', $webhook), [ + 'endpoint' => 'https://same.example.com/hook', + 'events' => [EventType::PostPublished->value], + ]) + ->assertRedirect(); +}); + +test('updating webhook status does not ping the endpoint', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $mock = Mockery::mock(WebhookService::class); + $mock->shouldNotReceive('assertEndpointAllowed'); + $mock->shouldNotReceive('ping'); + $this->app->instance(WebhookService::class, $mock); + + $this->actingAs($this->user) + ->put(route('app.webhooks.update', $webhook), [ + 'status' => 'disabled', + ]) + ->assertRedirect(); + + $webhook->refresh(); + + expect($webhook->status->value)->toBe('disabled'); +}); + +test('authenticated users can update webhook events', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'events' => [EventType::PostCreated->value], + ]); + + $this->actingAs($this->user) + ->put(route('app.webhooks.update', $webhook), [ + 'events' => [EventType::PostCreated->value, EventType::PostFailed->value], + ]) + ->assertRedirect(); + + $webhook->refresh(); + expect($webhook->events)->toEqual([ + EventType::PostCreated->value, + EventType::PostFailed->value, + ]); +}); + +test('authenticated users can update webhook status', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $this->actingAs($this->user) + ->put(route('app.webhooks.update', $webhook), [ + 'status' => 'disabled', + ]) + ->assertRedirect(); + + $webhook->refresh(); + expect($webhook->status->value)->toBe('disabled'); +}); + +test('update webhook rejects invalid status', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $this->actingAs($this->user) + ->put(route('app.webhooks.update', $webhook), [ + 'status' => 'invalid', + ]) + ->assertSessionHasErrors('status'); +}); + +test('update webhook rejects paused status from the user', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $this->actingAs($this->user) + ->put(route('app.webhooks.update', $webhook), [ + 'status' => 'paused', + ]) + ->assertSessionHasErrors('status'); +}); + +test('update webhook rejects wildcard events', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $this->actingAs($this->user) + ->put(route('app.webhooks.update', $webhook), [ + 'events' => ['*'], + ]) + ->assertSessionHasErrors('events.0'); +}); + +test('authenticated users can delete a webhook', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $this->actingAs($this->user) + ->delete(route('app.webhooks.destroy', $webhook)) + ->assertRedirect(route('app.webhooks.index')); + + $this->assertDatabaseMissing('webhooks', [ + 'id' => $webhook->id, + ]); +}); + +test('deleting a webhook also deletes its logs', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $log = WebhookLog::factory()->create([ + 'webhook_id' => $webhook->id, + ]); + + $this->actingAs($this->user) + ->delete(route('app.webhooks.destroy', $webhook)) + ->assertRedirect(); + + $this->assertDatabaseMissing('webhook_logs', [ + 'id' => $log->id, + ]); +}); + +test('users cannot view webhooks from other workspaces', function () { + $otherUser = User::factory()->create(); + $otherWorkspace = Workspace::factory()->create(['user_id' => $otherUser->id]); + $webhook = Webhook::factory()->create([ + 'workspace_id' => $otherWorkspace->id, + ]); + + $this->actingAs($this->user) + ->get(route('app.webhooks.show', $webhook)) + ->assertForbidden(); +}); + +test('users cannot update webhooks from other workspaces', function () { + $otherUser = User::factory()->create(); + $otherWorkspace = Workspace::factory()->create(['user_id' => $otherUser->id]); + $webhook = Webhook::factory()->create([ + 'workspace_id' => $otherWorkspace->id, + ]); + + $this->actingAs($this->user) + ->put(route('app.webhooks.update', $webhook), [ + 'endpoint' => 'https://hacker.com/steal', + ]) + ->assertForbidden(); +}); + +test('authenticated users can send a signed test event', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'endpoint' => 'https://example.com/hook', + ]); + + $mock = Mockery::mock(WebhookService::class); + $mock->shouldReceive('ping')->once()->with($webhook->endpoint, $webhook->signing_secret); + $this->app->instance(WebhookService::class, $mock); + + $this->actingAs($this->user) + ->post(route('app.webhooks.send-test', $webhook)) + ->assertRedirect() + ->assertSessionHas('flash.banner', __('webhooks.flash.tested')); +}); + +test('sending a test event flashes the error when ping fails', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $failingMock = Mockery::mock(WebhookService::class); + $failingMock->shouldReceive('ping')->andThrow(new RuntimeException('Connection refused')); + $this->app->instance(WebhookService::class, $failingMock); + + $this->actingAs($this->user) + ->post(route('app.webhooks.send-test', $webhook)) + ->assertRedirect() + ->assertSessionHas('flash.banner', 'Connection refused') + ->assertSessionHas('flash.bannerStyle', 'danger'); +}); + +test('users cannot send a test event for other workspaces webhooks', function () { + $otherUser = User::factory()->create(); + $otherWorkspace = Workspace::factory()->create(['user_id' => $otherUser->id]); + $webhook = Webhook::factory()->create([ + 'workspace_id' => $otherWorkspace->id, + ]); + + $this->actingAs($this->user) + ->post(route('app.webhooks.send-test', $webhook)) + ->assertForbidden(); +}); + +test('authenticated users can rotate a webhook signing secret', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $originalSecret = $webhook->signing_secret; + + $this->actingAs($this->user) + ->post(route('app.webhooks.rotate-secret', $webhook)) + ->assertRedirect() + ->assertSessionHas('flash.banner', __('webhooks.flash.secret_rotated')); + + $webhook->refresh(); + expect($webhook->signing_secret) + ->not->toBe($originalSecret) + ->toStartWith('whsec_'); + + $raw = DB::table('webhooks')->where('id', $webhook->id)->value('signing_secret'); + + expect($raw)->not->toStartWith('whsec_'); +}); + +test('users cannot rotate signing secret for other workspaces webhooks', function () { + $otherUser = User::factory()->create(); + $otherWorkspace = Workspace::factory()->create(['user_id' => $otherUser->id]); + $webhook = Webhook::factory()->create([ + 'workspace_id' => $otherWorkspace->id, + ]); + + $this->actingAs($this->user) + ->post(route('app.webhooks.rotate-secret', $webhook)) + ->assertForbidden(); +}); + +test('users cannot delete webhooks from other workspaces', function () { + $otherUser = User::factory()->create(); + $otherWorkspace = Workspace::factory()->create(['user_id' => $otherUser->id]); + $webhook = Webhook::factory()->create([ + 'workspace_id' => $otherWorkspace->id, + ]); + + $this->actingAs($this->user) + ->delete(route('app.webhooks.destroy', $webhook)) + ->assertForbidden(); +}); + +test('workspace admins can manage webhooks', function () { + $admin = teammateForWebhookWorkspace(Role::Admin); + + $this->actingAs($admin) + ->get(route('app.webhooks.index')) + ->assertOk(); + + $this->actingAs($admin) + ->post(route('app.webhooks.store'), [ + 'endpoint' => 'https://admin.example.com/webhooks', + 'events' => [EventType::PostPublished->value], + ]) + ->assertRedirect(); + + $webhook = Webhook::query()->where('endpoint', 'https://admin.example.com/webhooks')->first(); + + expect($webhook)->not->toBeNull(); + + $this->actingAs($admin) + ->get(route('app.webhooks.show', $webhook)) + ->assertOk(); + + $this->actingAs($admin) + ->put(route('app.webhooks.update', $webhook), [ + 'status' => 'disabled', + ]) + ->assertRedirect(); + + $this->actingAs($admin) + ->post(route('app.webhooks.send-test', $webhook)) + ->assertRedirect(); + + $this->actingAs($admin) + ->post(route('app.webhooks.rotate-secret', $webhook)) + ->assertRedirect(); + + $log = WebhookLog::factory()->create([ + 'webhook_id' => $webhook->id, + ]); + + Queue::fake(); + + $this->actingAs($admin) + ->post(route('app.webhooks.replay', [$webhook, $log])) + ->assertRedirect(); + + $this->actingAs($admin) + ->delete(route('app.webhooks.destroy', $webhook)) + ->assertRedirect(route('app.webhooks.index')); +}); + +test('members and viewers cannot manage webhooks', function (Role $role) { + $user = teammateForWebhookWorkspace($role); + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + $log = WebhookLog::factory()->create([ + 'webhook_id' => $webhook->id, + ]); + + $this->actingAs($user) + ->get(route('app.webhooks.index')) + ->assertForbidden(); + + $this->actingAs($user) + ->get(route('app.webhooks.show', $webhook)) + ->assertForbidden(); + + $this->actingAs($user) + ->post(route('app.webhooks.store'), [ + 'endpoint' => 'https://member.example.com/webhooks', + 'events' => [EventType::PostPublished->value], + ]) + ->assertForbidden(); + + $this->actingAs($user) + ->put(route('app.webhooks.update', $webhook), [ + 'endpoint' => 'https://stolen.example.com/hook', + ]) + ->assertForbidden(); + + $this->actingAs($user) + ->post(route('app.webhooks.send-test', $webhook)) + ->assertForbidden(); + + $this->actingAs($user) + ->post(route('app.webhooks.rotate-secret', $webhook)) + ->assertForbidden(); + + $this->actingAs($user) + ->post(route('app.webhooks.replay', [$webhook, $log])) + ->assertForbidden(); + + $this->actingAs($user) + ->delete(route('app.webhooks.destroy', $webhook)) + ->assertForbidden(); + + $this->assertDatabaseHas('webhooks', [ + 'id' => $webhook->id, + 'endpoint' => $webhook->endpoint, + ]); + $this->assertDatabaseMissing('webhooks', [ + 'endpoint' => 'https://member.example.com/webhooks', + ]); +})->with([ + Role::Member, + Role::Viewer, +]); + +function teammateForWebhookWorkspace(Role $role): User +{ + $user = User::factory()->create(['account_id' => test()->user->account_id]); + test()->workspace->members()->attach($user->id, ['role' => $role->value]); + $user->update(['current_workspace_id' => test()->workspace->id]); + + return $user->fresh(); +} diff --git a/tests/Feature/Automation/FeedInspectionTest.php b/tests/Feature/Automation/FeedInspectionTest.php index f7760875..420f8d84 100644 --- a/tests/Feature/Automation/FeedInspectionTest.php +++ b/tests/Feature/Automation/FeedInspectionTest.php @@ -161,18 +161,19 @@ ->assertSessionHasNoErrors(); }); -it('accepts a templated url on a webhook node', function () { +it('accepts a templated url on an HTTP request node', function () { $automation = Automation::factory()->for($this->workspace)->create(); $this->actingAs($this->user) ->put(route('app.automations.update', $automation->id), [ 'nodes' => [[ - 'id' => 'webhook_1', - 'type' => 'webhook', + 'id' => 'http_1', + 'type' => 'http_request', 'position' => ['x' => 0, 'y' => 0], 'data' => [ 'url' => 'https://hooks.example.com/{{ variables.TOKEN }}', 'method' => 'POST', + 'auth_type' => 'none', ], ]], ]) diff --git a/tests/Feature/Automation/Node/FetchRssNodeTest.php b/tests/Feature/Automation/Node/FetchRssNodeTest.php index 26f66daf..fb0b785b 100644 --- a/tests/Feature/Automation/Node/FetchRssNodeTest.php +++ b/tests/Feature/Automation/Node/FetchRssNodeTest.php @@ -132,12 +132,12 @@ ['id' => 'trigger_1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']], ['id' => 'fetch_1', 'type' => 'fetch_rss', 'position' => ['x' => 200, 'y' => 0], 'data' => ['feed_url' => 'https://1.1.1.1/feed']], ['id' => 'generate_1', 'type' => 'generate', 'position' => ['x' => 400, 'y' => 0], 'data' => []], - ['id' => 'webhook_1', 'type' => 'webhook', 'position' => ['x' => 400, 'y' => 200], 'data' => []], + ['id' => 'end_1', 'type' => 'end', 'position' => ['x' => 400, 'y' => 200], 'data' => []], ], 'connections' => [ ['id' => 'e1', 'source' => 'trigger_1', 'target' => 'fetch_1'], ['id' => 'e2', 'source' => 'fetch_1', 'source_handle' => 'default', 'target' => 'generate_1'], - ['id' => 'e3', 'source' => 'fetch_1', 'source_handle' => 'default', 'target' => 'webhook_1'], + ['id' => 'e3', 'source' => 'fetch_1', 'source_handle' => 'default', 'target' => 'end_1'], ], ]); @@ -154,7 +154,7 @@ // 2 spawned items × 2 branches = 4 dispatches; both branches reached. Bus::assertDispatchedTimes(ProcessAutomationNode::class, 4); Bus::assertDispatched(ProcessAutomationNode::class, fn ($job) => $job->nodeId === 'generate_1'); - Bus::assertDispatched(ProcessAutomationNode::class, fn ($job) => $job->nodeId === 'webhook_1'); + Bus::assertDispatched(ProcessAutomationNode::class, fn ($job) => $job->nodeId === 'end_1'); }); it('does not persist the production watermark on a manual real-data test', function () { diff --git a/tests/Feature/Automation/Node/WebhookNodeTest.php b/tests/Feature/Automation/Node/WebhookNodeTest.php deleted file mode 100644 index 6467a995..00000000 --- a/tests/Feature/Automation/Node/WebhookNodeTest.php +++ /dev/null @@ -1,238 +0,0 @@ - Http::response(['ok' => true], 200), - ]); - - $run = AutomationRun::factory()->create([ - 'context' => ['trigger' => ['title' => 'Hello'], 'generated' => ['post_url' => 'https://t.it/p/1']], - ]); - - $result = app(RunWebhookNode::class)($run, [ - 'url' => 'https://1.1.1.1/test', - 'method' => 'POST', - 'headers' => ['X-Source' => 'TryPost'], - 'payload_template' => '{"title":"{{ trigger.title }}","post_url":"{{ generated.post_url }}"}', - ]); - - expect($result->status)->toBe(Status::Completed); - Http::assertSent(fn ($request) => $request['title'] === 'Hello' && $request['post_url'] === 'https://t.it/p/1'); -}); - -it('sends the branded user-agent header', function () { - Http::fake([ - '1.1.1.1/*' => Http::response(['ok' => true], 200), - ]); - - $run = AutomationRun::factory()->create(); - - app(RunWebhookNode::class)($run, [ - 'url' => 'https://1.1.1.1/test', - 'method' => 'POST', - 'headers' => ['User-Agent' => 'user-supplied-agent'], - 'payload_template' => '{}', - ]); - - Http::assertSent(fn ($request) => $request->hasHeader('User-Agent', config('trypost.user_agent'))); -}); - -it('escapes special characters in templated payload values so the JSON stays valid', function () { - Http::fake(['1.1.1.1/*' => Http::response(['ok' => true], 200)]); - - $run = AutomationRun::factory()->create([ - 'context' => ['fetched' => ['title' => 'He said "hi" & bye']], - ]); - - $result = app(RunWebhookNode::class)($run, [ - 'url' => 'https://1.1.1.1/hook', - 'method' => 'POST', - 'payload_template' => '{"title":"{{ fetched.title }}"}', - ]); - - expect($result->status)->toBe(Status::Completed); - Http::assertSent(fn ($request) => $request['title'] === 'He said "hi" & bye'); -}); - -it('fails on 5xx response', function () { - Http::fake(['1.1.1.1/*' => Http::response('err', 500)]); - - $run = AutomationRun::factory()->create(); - - $result = app(RunWebhookNode::class)($run, [ - 'url' => 'https://1.1.1.1/test', - 'method' => 'POST', - 'payload_template' => '{}', - ]); - - expect($result->status)->toBe(Status::Failed); -}); - -it('fails on malformed payload json instead of silently sending an empty body', function () { - Http::fake(['1.1.1.1/*' => Http::response(['ok' => true], 200)]); - - $run = AutomationRun::factory()->create(); - - $result = app(RunWebhookNode::class)($run, [ - 'url' => 'https://1.1.1.1/test', - 'method' => 'POST', - 'payload_template' => '{ "a": }', - ]); - - expect($result->status)->toBe(Status::Failed); - expect($result->error['reason'])->toBe('invalid_payload_json'); - Http::assertNothingSent(); -}); - -it('does not fire a real request on a dry run', function () { - Http::fake(['1.1.1.1/*' => Http::response(['ok' => true], 200)]); - - $run = AutomationRun::factory()->create(['is_dry_run' => true]); - - $result = app(RunWebhookNode::class)($run, [ - 'url' => 'https://1.1.1.1/test', - 'method' => 'POST', - 'payload_template' => '{"a":1}', - ]); - - expect($result->status)->toBe(Status::Completed); - expect($result->output['webhook']['dry_run'])->toBeTrue(); - Http::assertNothingSent(); -}); - -it('still validates payload json on a dry run', function () { - Http::fake(['1.1.1.1/*' => Http::response(['ok' => true], 200)]); - - $run = AutomationRun::factory()->create(['is_dry_run' => true]); - - $result = app(RunWebhookNode::class)($run, [ - 'url' => 'https://1.1.1.1/test', - 'method' => 'POST', - 'payload_template' => '{ "a": }', - ]); - - expect($result->status)->toBe(Status::Failed); - expect($result->error['reason'])->toBe('invalid_payload_json'); - Http::assertNothingSent(); -}); - -it('fails with a clear error when the url is missing', function () { - Http::fake(); - - $run = AutomationRun::factory()->create(); - - $result = app(RunWebhookNode::class)($run, [ - 'method' => 'POST', - 'payload_template' => '{}', - ]); - - expect($result->status)->toBe(Status::Failed); - expect($result->error['reason'])->toBe('missing_url'); - Http::assertNothingSent(); -}); - -it('blocks a request to a private or reserved address', function () { - Http::fake(); - - $run = AutomationRun::factory()->create(); - - $result = app(RunWebhookNode::class)($run, [ - 'url' => 'http://169.254.169.254/latest/meta-data/', - 'method' => 'POST', - 'payload_template' => '{}', - ]); - - expect($result->status)->toBe(Status::Failed); - expect($result->error['reason'])->toBe('url_not_allowed'); - Http::assertNothingSent(); -}); - -it('treats 4xx responses as completed (only 5xx fails)', function () { - Http::fake(['1.1.1.1/*' => Http::response('not found', 404)]); - - $run = AutomationRun::factory()->create(); - - $result = app(RunWebhookNode::class)($run, [ - 'url' => 'https://1.1.1.1/test', - 'method' => 'POST', - 'payload_template' => '{}', - ]); - - expect($result->status->value)->toBe('completed'); - expect($result->output['webhook']['status'])->toBe(404); -}); - -it('sends the configured HTTP method', function (string $method) { - Http::fake(['1.1.1.1/*' => Http::response(['ok' => true], 200)]); - - $run = AutomationRun::factory()->create(); - - $result = app(RunWebhookNode::class)($run, [ - 'url' => 'https://1.1.1.1/hook', - 'method' => $method, - 'payload_template' => '{}', - ]); - - expect($result->status)->toBe(Status::Completed); - Http::assertSent(fn ($request) => $request->method() === $method); -})->with(['PUT', 'PATCH', 'DELETE', 'GET']); - -it('resolves expressions in custom header values', function () { - Http::fake(['1.1.1.1/*' => Http::response(['ok' => true], 200)]); - - $run = AutomationRun::factory()->create(['context' => ['trigger' => ['title' => 'tok-123']]]); - - app(RunWebhookNode::class)($run, [ - 'url' => 'https://1.1.1.1/hook', - 'method' => 'POST', - 'headers' => ['X-Token' => '{{ trigger.title }}'], - 'payload_template' => '{}', - ]); - - Http::assertSent(fn ($request) => $request->hasHeader('X-Token', 'tok-123')); -}); - -it('never follows a redirect to a private or internal host', function () { - Http::fake([ - 'https://93.184.216.34/*' => Http::response('', 302, ['Location' => 'http://127.0.0.1/internal']), - 'http://127.0.0.1/*' => Http::response('internal secret', 200), - ]); - - $run = AutomationRun::factory()->create(); - - $result = app(RunWebhookNode::class)($run, [ - 'url' => 'https://93.184.216.34/hook', - 'method' => 'POST', - 'payload_template' => '{}', - ]); - - // The 3xx is returned as-is (not followed), so the node completes with the - // redirect status rather than the internal host's response. - expect($result->status)->toBe(Status::Completed); - expect($result->output['webhook']['status'])->toBe(302); - Http::assertNotSent(fn ($request) => str_contains($request->url(), '127.0.0.1')); -}); - -it('fails cleanly when the request throws a connection exception', function () { - Http::fake(fn () => throw new ConnectionException('connection timed out')); - - $run = AutomationRun::factory()->create(); - - $result = app(RunWebhookNode::class)($run, [ - 'url' => 'https://1.1.1.1/hook', - 'method' => 'POST', - 'payload_template' => '{}', - ]); - - expect($result->status)->toBe(Status::Failed); - expect($result->error['reason'])->toBe('request_failed'); - expect($result->error['message'])->toContain('connection timed out'); -}); diff --git a/tests/Feature/Automation/Run/FanOutTest.php b/tests/Feature/Automation/Run/FanOutTest.php index 7d747b81..e842f210 100644 --- a/tests/Feature/Automation/Run/FanOutTest.php +++ b/tests/Feature/Automation/Run/FanOutTest.php @@ -48,7 +48,7 @@ 'nodes' => [ ['id' => 'g', 'type' => 'generate', 'position' => ['x' => 0, 'y' => 0], 'data' => []], ['id' => 'p', 'type' => 'publish', 'position' => ['x' => 1, 'y' => 0], 'data' => ['mode' => 'now']], - ['id' => 'w', 'type' => 'webhook', 'position' => ['x' => 1, 'y' => 1], 'data' => []], + ['id' => 'w', 'type' => 'end', 'position' => ['x' => 1, 'y' => 1], 'data' => []], ], 'connections' => [ ['id' => 'e1', 'source' => 'g', 'target' => 'p'], diff --git a/tests/Feature/Automation/Run/UnknownNodeTypeTest.php b/tests/Feature/Automation/Run/UnknownNodeTypeTest.php new file mode 100644 index 00000000..0104d3cd --- /dev/null +++ b/tests/Feature/Automation/Run/UnknownNodeTypeTest.php @@ -0,0 +1,27 @@ +active()->create([ + 'nodes' => [['id' => 'legacy', 'type' => 'webhook', 'position' => ['x' => 0, 'y' => 0], 'data' => []]], + 'connections' => [], + ]); + $run = AutomationRun::factory()->for($automation)->create(['status' => RunStatus::Pending]); + + (new ProcessAutomationNode($run, 'legacy'))->handle(app(AdvanceAutomationRun::class)); + + $run->refresh(); + + expect($run->status)->toBe(RunStatus::Failed); + expect(data_get($run->error, 'message'))->toBe(__('automations.errors.node_no_longer_exists', [ + 'node_id' => 'legacy', + ])); + expect($run->finished_at)->not->toBeNull(); +}); diff --git a/tests/Feature/Automation/Tree/ContentPipelineTreesTest.php b/tests/Feature/Automation/Tree/ContentPipelineTreesTest.php index b1eef5da..6400c0c0 100644 --- a/tests/Feature/Automation/Tree/ContentPipelineTreesTest.php +++ b/tests/Feature/Automation/Tree/ContentPipelineTreesTest.php @@ -148,7 +148,7 @@ expect($post->content)->toBe('Post from RSS item'); }); -it('runs http_request → condition (true) → webhook down the yes handle', function () { +it('runs http_request → condition (true) → notify down the yes handle', function () { Http::fake([ '8.8.8.8/*' => Http::response(['status' => 'active'], 200), '9.9.9.9/*' => Http::response(['ok' => true], 200), @@ -161,7 +161,7 @@ ['id' => 't', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']], ['id' => 'h', 'type' => 'http_request', 'position' => ['x' => 1, 'y' => 0], 'data' => ['url' => 'https://8.8.8.8/status', 'method' => 'GET']], ['id' => 'c', 'type' => 'condition', 'position' => ['x' => 2, 'y' => 0], 'data' => ['field' => '{{ fetched.status }}', 'operator' => 'equals', 'value' => 'active']], - ['id' => 'w', 'type' => 'webhook', 'position' => ['x' => 3, 'y' => 0], 'data' => ['url' => 'https://9.9.9.9/notify', 'method' => 'POST', 'payload_template' => '{"ok":true}']], + ['id' => 'w', 'type' => 'http_request', 'position' => ['x' => 3, 'y' => 0], 'data' => ['url' => 'https://9.9.9.9/notify', 'method' => 'POST', 'auth_type' => 'none', 'body_template' => '{"ok":true}']], ['id' => 'e', 'type' => 'end', 'position' => ['x' => 3, 'y' => 1], 'data' => []], ], 'connections' => [ @@ -180,7 +180,7 @@ Http::assertSent(fn ($request) => str_contains($request->url(), '9.9.9.9/notify')); }); -it('runs http_request → condition (false) → end down the no handle without hitting the webhook', function () { +it('runs http_request → condition (false) → end down the no handle without hitting the notify request', function () { Http::fake([ '8.8.8.8/*' => Http::response(['status' => 'inactive'], 200), '9.9.9.9/*' => Http::response(['ok' => true], 200), @@ -193,7 +193,7 @@ ['id' => 't', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']], ['id' => 'h', 'type' => 'http_request', 'position' => ['x' => 1, 'y' => 0], 'data' => ['url' => 'https://8.8.8.8/status', 'method' => 'GET']], ['id' => 'c', 'type' => 'condition', 'position' => ['x' => 2, 'y' => 0], 'data' => ['field' => '{{ fetched.status }}', 'operator' => 'equals', 'value' => 'active']], - ['id' => 'w', 'type' => 'webhook', 'position' => ['x' => 3, 'y' => 0], 'data' => ['url' => 'https://9.9.9.9/notify', 'method' => 'POST', 'payload_template' => '{"ok":true}']], + ['id' => 'w', 'type' => 'http_request', 'position' => ['x' => 3, 'y' => 0], 'data' => ['url' => 'https://9.9.9.9/notify', 'method' => 'POST', 'auth_type' => 'none', 'body_template' => '{"ok":true}']], ['id' => 'e', 'type' => 'end', 'position' => ['x' => 3, 'y' => 1], 'data' => []], ], 'connections' => [ diff --git a/tests/Feature/Automation/VariablesTest.php b/tests/Feature/Automation/VariablesTest.php index ec61f095..65a6b853 100644 --- a/tests/Feature/Automation/VariablesTest.php +++ b/tests/Feature/Automation/VariablesTest.php @@ -4,7 +4,7 @@ use App\Actions\Automation\Node\RunConditionNode; use App\Actions\Automation\Node\RunFetchRssNode; -use App\Actions\Automation\Node\RunWebhookNode; +use App\Actions\Automation\Node\RunHttpRequestNode; use App\Enums\UserWorkspace\Role; use App\Http\Resources\AutomationResource; use App\Models\Automation; @@ -88,7 +88,7 @@ expect($run->fresh()->context)->not->toHaveKey('variables'); }); -it('resolves a variable in a webhook payload without persisting it in the run context', function () { +it('resolves a variable in an HTTP request body without persisting it in the run context', function () { Http::fake(['1.1.1.1/*' => Http::response(['ok' => true], 200)]); $automation = Automation::factory()->for($this->workspace)->create([ @@ -96,10 +96,11 @@ ]); $run = AutomationRun::factory()->for($automation)->create(['context' => []]); - app(RunWebhookNode::class)($run, [ + app(RunHttpRequestNode::class)($run, [ 'url' => 'https://1.1.1.1/hook', 'method' => 'POST', - 'payload_template' => '{"token":"{{ variables.TOKEN }}"}', + 'auth_type' => 'none', + 'body_template' => '{"token":"{{ variables.TOKEN }}"}', ]); Http::assertSent(fn ($request) => $request['token'] === 'abc123'); diff --git a/tests/Feature/Automation/WebhookNodeValidationTest.php b/tests/Feature/Automation/WebhookNodeValidationTest.php deleted file mode 100644 index 2ab77a5c..00000000 --- a/tests/Feature/Automation/WebhookNodeValidationTest.php +++ /dev/null @@ -1,109 +0,0 @@ -user = User::factory()->create(); - $this->workspace = Workspace::factory()->create(['account_id' => $this->user->account_id]); - $this->user->update(['current_workspace_id' => $this->workspace->id]); - $this->workspace->members()->attach($this->user->id, ['role' => Role::Admin->value]); - $this->user->refresh(); -}); - -it('rejects saving a webhook node whose payload template is not valid JSON', function () { - $automation = Automation::factory()->for($this->workspace)->create(); - - $this->actingAs($this->user) - ->putJson(route('app.automations.update', $automation->id), [ - 'nodes' => [ - ['id' => 'n1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule', 'cron' => '0 9 * * *']], - ['id' => 'n2', 'type' => 'webhook', 'position' => ['x' => 1, 'y' => 0], 'data' => [ - 'url' => 'https://example.test/hook', - 'method' => 'POST', - 'payload_template' => '{"title": {{fetched.title}}}', - ]], - ], - 'connections' => [['id' => 'e1', 'source' => 'n1', 'target' => 'n2']], - ]) - ->assertStatus(422) - ->assertJsonValidationErrors(['nodes.1.data.payload_template']); -}); - -it('allows saving a webhook node whose placeholders are quoted valid JSON', function () { - $automation = Automation::factory()->for($this->workspace)->create(); - - $this->actingAs($this->user) - ->put(route('app.automations.update', $automation->id), [ - 'nodes' => [ - ['id' => 'n1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule', 'cron' => '0 9 * * *']], - ['id' => 'n2', 'type' => 'webhook', 'position' => ['x' => 1, 'y' => 0], 'data' => [ - 'url' => 'https://example.test/hook', - 'method' => 'POST', - 'payload_template' => '{"title": "{{fetched.title}}"}', - ]], - ], - 'connections' => [['id' => 'e1', 'source' => 'n1', 'target' => 'n2']], - ]) - ->assertSessionHasNoErrors(); - - expect($automation->fresh()->nodes)->toHaveCount(2); -}); - -it('allows saving a webhook node with an empty payload template', function () { - $automation = Automation::factory()->for($this->workspace)->create(); - - $this->actingAs($this->user) - ->put(route('app.automations.update', $automation->id), [ - 'nodes' => [ - ['id' => 'n1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule', 'cron' => '0 9 * * *']], - ['id' => 'n2', 'type' => 'webhook', 'position' => ['x' => 1, 'y' => 0], 'data' => [ - 'url' => 'https://example.test/hook', - 'method' => 'POST', - 'payload_template' => '', - ]], - ], - 'connections' => [['id' => 'e1', 'source' => 'n1', 'target' => 'n2']], - ]) - ->assertSessionHasNoErrors(); -}); - -it('refuses to activate an automation whose webhook payload is invalid JSON', function () { - $automation = Automation::factory()->for($this->workspace)->withScheduleTrigger()->create(); - $automation->update([ - 'nodes' => array_merge($automation->nodes, [ - ['id' => 'n2', 'type' => 'webhook', 'position' => ['x' => 1, 'y' => 1], 'data' => [ - 'url' => 'https://example.test/hook', - 'payload_template' => '{"title": {{fetched.title}}}', - ]], - ]), - 'connections' => [['id' => 'e1', 'source' => 'trigger_1', 'target' => 'n2']], - ]); - - $this->actingAs($this->user) - ->postJson(route('app.automations.activate', $automation->id)) - ->assertStatus(422); - - expect($automation->fresh()->status->value)->not->toBe('active'); -}); - -it('refuses to run a test when the webhook payload is invalid JSON', function () { - $automation = Automation::factory()->for($this->workspace)->withScheduleTrigger()->create(); - $automation->update([ - 'nodes' => array_merge($automation->nodes, [ - ['id' => 'n2', 'type' => 'webhook', 'position' => ['x' => 1, 'y' => 1], 'data' => [ - 'url' => 'https://example.test/hook', - 'payload_template' => '{"title": {{fetched.title}}}', - ]], - ]), - 'connections' => [['id' => 'e1', 'source' => 'trigger_1', 'target' => 'n2']], - ]); - - $this->actingAs($this->user) - ->postJson(route('app.automations.test', $automation->id), []) - ->assertStatus(422); -}); diff --git a/tests/Feature/Console/PruneWebhookLogsTest.php b/tests/Feature/Console/PruneWebhookLogsTest.php new file mode 100644 index 00000000..90c09372 --- /dev/null +++ b/tests/Feature/Console/PruneWebhookLogsTest.php @@ -0,0 +1,47 @@ +create(); + + $old = WebhookLog::factory()->create([ + 'webhook_id' => $webhook->id, + 'created_at' => now()->subDays(8), + ]); + + $recent = WebhookLog::factory()->create([ + 'webhook_id' => $webhook->id, + 'created_at' => now()->subDays(3), + ]); + + $this->artisan('app:prune-webhook-logs') + ->assertSuccessful(); + + expect(WebhookLog::find($old->id))->toBeNull(); + expect(WebhookLog::find($recent->id))->not->toBeNull(); +}); + +test('keeps logs exactly 7 days old', function () { + $webhook = Webhook::factory()->create(); + + $borderline = WebhookLog::factory()->create([ + 'webhook_id' => $webhook->id, + 'created_at' => now()->subDays(7), + ]); + + $this->artisan('app:prune-webhook-logs') + ->assertSuccessful(); + + expect(WebhookLog::find($borderline->id))->not->toBeNull(); +}); + +test('handles empty table gracefully', function () { + $this->artisan('app:prune-webhook-logs') + ->assertSuccessful(); + + expect(WebhookLog::count())->toBe(0); +}); diff --git a/tests/Feature/Services/WebhookServiceTest.php b/tests/Feature/Services/WebhookServiceTest.php new file mode 100644 index 00000000..150ca88d --- /dev/null +++ b/tests/Feature/Services/WebhookServiceTest.php @@ -0,0 +1,490 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->service = app(WebhookService::class); +}); + +test('ping sends a signed POST request to the endpoint', function () { + Http::fake([ + 'https://example.com/hook' => Http::response([], 200), + ]); + + $secret = 'whsec_test_secret'; + + $this->service->ping('https://example.com/hook', $secret); + + Http::assertSent(function ($request) use ($secret) { + $body = $request->data(); + $raw = $request->body(); + + return $request->url() === 'https://example.com/hook' + && $request->method() === 'POST' + && $body['type'] === 'webhook.test' + && str_contains($raw, '"data":{}') + && isset($body['id'], $body['created_at']) + && $request->hasHeader('X-Webhook-Signature') + && $request->header('X-Webhook-Signature')[0] === hash_hmac('sha256', $raw, $secret); + }); +}); + +test('ping throws when endpoint is unreachable', function () { + Http::fake([ + 'https://example.com/unreachable' => Http::throw(fn () => throw new ConnectionException('Connection refused')), + ]); + + $this->service->ping('https://example.com/unreachable', 'whsec_test_secret'); +})->throws(RuntimeException::class, 'The endpoint is not reachable.'); + +test('ping throws when endpoint returns non-200', function () { + Http::fake([ + 'https://example.com/hook' => Http::response([], 500), + ]); + + $this->service->ping('https://example.com/hook', 'whsec_test_secret'); +})->throws(RuntimeException::class, 'The endpoint returned HTTP 500.'); + +test('ping rejects private network endpoints', function () { + $this->service->ping('http://127.0.0.1/hook', 'whsec_test_secret'); +})->throws(RuntimeException::class, 'This endpoint is not allowed.'); + +test('assertEndpointAllowed rejects private network endpoints', function () { + $this->service->assertEndpointAllowed('http://127.0.0.1/hook'); +})->throws(RuntimeException::class, 'This endpoint is not allowed.'); + +test('dispatch dispatches DispatchWebhook for matching webhooks', function () { + Queue::fake(); + + Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'events' => [WebhookEvent::PostPublished->value, WebhookEvent::PostFailed->value], + ]); + + $this->service->dispatch($this->workspace, WebhookEvent::PostPublished, ['foo' => 'bar']); + + Queue::assertPushed(DispatchWebhook::class); +}); + +test('dispatch does not dispatch for webhooks without matching events', function () { + Queue::fake(); + + Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'events' => [WebhookEvent::PostFailed->value], + ]); + + $this->service->dispatch($this->workspace, WebhookEvent::PostPublished, ['foo' => 'bar']); + + Queue::assertNotPushed(DispatchWebhook::class); +}); + +test('dispatch does not dispatch for disabled webhooks', function () { + Queue::fake(); + + Webhook::factory()->disabled()->create([ + 'workspace_id' => $this->workspace->id, + 'events' => [WebhookEvent::PostPublished->value], + ]); + + $this->service->dispatch($this->workspace, WebhookEvent::PostPublished, ['foo' => 'bar']); + + Queue::assertNotPushed(DispatchWebhook::class); +}); + +test('dispatch does not dispatch for paused webhooks', function () { + Queue::fake(); + + Webhook::factory()->paused()->create([ + 'workspace_id' => $this->workspace->id, + 'events' => [WebhookEvent::PostCreated->value, WebhookEvent::PostPublished->value], + ]); + + $this->service->dispatch($this->workspace, WebhookEvent::PostCreated, ['foo' => 'bar']); + + Queue::assertNotPushed(DispatchWebhook::class); +}); + +test('dispatch does not match wildcard events', function () { + Queue::fake(); + + Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'events' => ['*'], + ]); + + $this->service->dispatch($this->workspace, WebhookEvent::PostPublished, ['foo' => 'bar']); + + Queue::assertNotPushed(DispatchWebhook::class); +}); + +test('postPayload includes the post lifecycle fields', function () { + $post = Post::factory()->published()->createQuietly([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'content' => 'Hello world', + 'created_via' => CreatedVia::Web, + ]); + + $payload = $this->service->postPayload($post); + + expect($payload)->toEqual([ + 'id' => $post->id, + 'workspace_id' => $post->workspace_id, + 'user_id' => $this->user->id, + 'status' => 'published', + 'created_via' => CreatedVia::Web->value, + 'content' => 'Hello world', + 'scheduled_at' => null, + 'published_at' => $post->published_at?->toIso8601String(), + 'created_at' => $post->created_at?->toIso8601String(), + 'updated_at' => $post->updated_at?->toIso8601String(), + 'author' => [ + 'id' => $this->user->id, + 'name' => $this->user->name, + ], + 'workspace' => [ + 'id' => $this->workspace->id, + 'name' => $this->workspace->name, + ], + 'labels' => [], + 'media' => [], + 'platforms' => [], + ]); +}); + +test('postPayload matches the published webhook example', function () { + $post = Post::factory()->published()->createQuietly([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'content' => '

Launch day. TryPost is live.

', + 'created_via' => CreatedVia::Web, + 'media' => [ + [ + 'id' => 'm_01', + 'path' => 'medias/9f2c-hero.jpg', + 'url' => 'https://cdn.example.com/medias/9f2c-hero.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'hero.jpg', + 'source' => Source::Unsplash->value, + 'source_meta' => ['photographer' => 'Ada'], + 'meta' => [ + 'width' => 1080, + 'height' => 1350, + 'alt_text' => 'Product screenshot on a laptop', + ], + ], + [ + 'id' => 'm_02', + 'path' => 'medias/9f2c-clip.mp4', + 'url' => 'https://cdn.example.com/medias/9f2c-clip.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'clip.mp4', + 'source' => Source::Ai->value, + 'source_meta' => null, + 'meta' => [ + 'width' => 1080, + 'height' => 1920, + 'duration' => 18.4, + ], + ], + ], + ]); + + $label = WorkspaceLabel::factory()->recycle($this->workspace)->create([ + 'name' => 'Launch', + 'color' => '#7C3AED', + ]); + $post->labels()->attach($label); + + $instagram = SocialAccount::factory()->instagram()->recycle($this->workspace)->create([ + 'display_name' => 'TryPost', + 'username' => 'trypost', + 'avatar_url' => 'avatars/ig.jpg', + 'access_token' => 'secret-access-token', + 'refresh_token' => 'secret-refresh-token', + ]); + $linkedin = SocialAccount::factory()->linkedin()->recycle($this->workspace)->create([ + 'display_name' => 'Paulo Castellano', + 'username' => 'paulocastellano', + 'avatar_url' => 'avatars/li.jpg', + ]); + $x = SocialAccount::factory()->x()->recycle($this->workspace)->create([ + 'display_name' => 'TryPost', + 'username' => 'trypost', + 'avatar_url' => 'avatars/x.jpg', + ]); + + $instagramPlatform = PostPlatform::factory()->published()->recycle($post, $instagram)->create([ + 'platform' => Platform::Instagram, + 'content_type' => ContentType::InstagramFeed, + 'meta' => ['aspect_ratio' => '4:5'], + ]); + $linkedinPlatform = PostPlatform::factory()->published()->recycle($post, $linkedin)->create([ + 'platform' => Platform::LinkedIn, + 'content_type' => ContentType::LinkedInPost, + 'meta' => ['document_title' => 'TryPost launch deck'], + ]); + $xPlatform = PostPlatform::factory()->published()->recycle($post, $x)->create([ + 'platform' => Platform::X, + 'content_type' => ContentType::XPost, + 'meta' => [], + ]); + + $payload = $this->service->postPayload($post->fresh()); + $platforms = collect($payload['platforms'])->keyBy('id'); + + expect($payload) + ->toHaveKeys([ + 'id', + 'workspace_id', + 'user_id', + 'status', + 'created_via', + 'content', + 'scheduled_at', + 'published_at', + 'created_at', + 'updated_at', + 'author', + 'workspace', + 'labels', + 'media', + 'platforms', + ]) + ->and($payload['id'])->toBe($post->id) + ->and($payload['workspace_id'])->toBe($this->workspace->id) + ->and($payload['user_id'])->toBe($this->user->id) + ->and($payload['status'])->toBe('published') + ->and($payload['created_via'])->toBe(CreatedVia::Web->value) + ->and($payload['content'])->toBe('

Launch day. TryPost is live.

') + ->and($payload['author'])->toEqual([ + 'id' => $this->user->id, + 'name' => $this->user->name, + ]) + ->and($payload['author'])->not->toHaveKey('email') + ->and($payload['workspace'])->toEqual([ + 'id' => $this->workspace->id, + 'name' => $this->workspace->name, + ]) + ->and($payload['labels'])->toEqual([ + [ + 'id' => $label->id, + 'name' => 'Launch', + 'color' => '#7C3AED', + ], + ]) + ->and($payload['media'])->toEqual([ + [ + 'id' => 'm_01', + 'path' => 'medias/9f2c-hero.jpg', + 'url' => 'https://cdn.example.com/medias/9f2c-hero.jpg', + 'type' => 'image', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'hero.jpg', + 'source' => 'unsplash', + 'source_meta' => ['photographer' => 'Ada'], + 'meta' => [ + 'width' => 1080, + 'height' => 1350, + 'alt_text' => 'Product screenshot on a laptop', + ], + ], + [ + 'id' => 'm_02', + 'path' => 'medias/9f2c-clip.mp4', + 'url' => 'https://cdn.example.com/medias/9f2c-clip.mp4', + 'type' => 'video', + 'mime_type' => 'video/mp4', + 'original_filename' => 'clip.mp4', + 'source' => 'ai', + 'source_meta' => null, + 'meta' => [ + 'width' => 1080, + 'height' => 1920, + 'duration' => 18.4, + ], + ], + ]) + ->and($payload['platforms'])->toHaveCount(3) + ->and($platforms[$instagramPlatform->id])->toEqual([ + 'id' => $instagramPlatform->id, + 'social_account_id' => $instagram->id, + 'platform' => Platform::Instagram->value, + 'content_type' => ContentType::InstagramFeed->value, + 'enabled' => true, + 'status' => 'published', + 'platform_post_id' => $instagramPlatform->platform_post_id, + 'platform_url' => $instagramPlatform->platform_url, + 'published_at' => $instagramPlatform->published_at?->toIso8601String(), + 'error_message' => null, + 'error_context' => null, + 'display_name' => 'TryPost', + 'display_username' => 'trypost', + 'display_avatar' => Storage::url('avatars/ig.jpg'), + 'meta' => ['aspect_ratio' => '4:5'], + 'social_account' => [ + 'id' => $instagram->id, + 'platform' => Platform::Instagram->value, + 'display_name' => 'TryPost', + 'username' => 'trypost', + 'is_active' => true, + 'status' => 'connected', + ], + ]) + ->and($platforms[$linkedinPlatform->id])->toEqual([ + 'id' => $linkedinPlatform->id, + 'social_account_id' => $linkedin->id, + 'platform' => Platform::LinkedIn->value, + 'content_type' => ContentType::LinkedInPost->value, + 'enabled' => true, + 'status' => 'published', + 'platform_post_id' => $linkedinPlatform->platform_post_id, + 'platform_url' => $linkedinPlatform->platform_url, + 'published_at' => $linkedinPlatform->published_at?->toIso8601String(), + 'error_message' => null, + 'error_context' => null, + 'display_name' => 'Paulo Castellano', + 'display_username' => 'paulocastellano', + 'display_avatar' => Storage::url('avatars/li.jpg'), + 'meta' => ['document_title' => 'TryPost launch deck'], + 'social_account' => [ + 'id' => $linkedin->id, + 'platform' => Platform::LinkedIn->value, + 'display_name' => 'Paulo Castellano', + 'username' => 'paulocastellano', + 'is_active' => true, + 'status' => 'connected', + ], + ]) + ->and($platforms[$xPlatform->id])->toEqual([ + 'id' => $xPlatform->id, + 'social_account_id' => $x->id, + 'platform' => Platform::X->value, + 'content_type' => ContentType::XPost->value, + 'enabled' => true, + 'status' => 'published', + 'platform_post_id' => $xPlatform->platform_post_id, + 'platform_url' => $xPlatform->platform_url, + 'published_at' => $xPlatform->published_at?->toIso8601String(), + 'error_message' => null, + 'error_context' => null, + 'display_name' => 'TryPost', + 'display_username' => 'trypost', + 'display_avatar' => Storage::url('avatars/x.jpg'), + 'meta' => [], + 'social_account' => [ + 'id' => $x->id, + 'platform' => Platform::X->value, + 'display_name' => 'TryPost', + 'username' => 'trypost', + 'is_active' => true, + 'status' => 'connected', + ], + ]) + ->and(json_encode($payload))->not->toContain('secret-access-token') + ->and(json_encode($payload))->not->toContain('secret-refresh-token') + ->and(json_encode($payload))->not->toContain('access_token') + ->and(json_encode($payload))->not->toContain('refresh_token'); +}); + +test('postPayload includes failed platform errors', function () { + $post = Post::factory()->published()->createQuietly([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + + $account = SocialAccount::factory()->tiktok()->recycle($this->workspace)->create(); + $platform = PostPlatform::factory()->failed()->recycle($post, $account)->create([ + 'platform' => Platform::TikTok, + 'content_type' => ContentType::TikTokVideo, + 'meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE'], + 'error_context' => ['retry_count' => 2], + ]); + + $payload = $this->service->postPayload($post->fresh()); + + expect(data_get($payload, 'platforms.0.id'))->toBe($platform->id) + ->and(data_get($payload, 'platforms.0.status'))->toBe('failed') + ->and(data_get($payload, 'platforms.0.error_message'))->toBe('Failed to publish') + ->and(data_get($payload, 'platforms.0.error_context'))->toEqual(['retry_count' => 2]) + ->and(data_get($payload, 'platforms.0.meta.privacy_level'))->toBe('PUBLIC_TO_EVERYONE'); +}); + +test('postPayload accepts integer media ids from generated attachments', function () { + $post = Post::factory()->published()->createQuietly([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'media' => [ + [ + 'id' => 42, + 'path' => 'medias/generated.png', + 'url' => 'https://cdn.example.com/medias/generated.png', + 'mime_type' => 'image/png', + ], + ], + ]); + + $payload = $this->service->postPayload($post); + + expect(data_get($payload, 'media.0.id'))->toBe('42') + ->and(data_get($payload, 'media.0.type'))->toBe('image'); +}); + +test('postPayload author is null when the post has no user', function () { + $post = Post::factory()->published()->createQuietly([ + 'workspace_id' => $this->workspace->id, + 'user_id' => null, + 'content' => 'Orphan post', + ]); + + $payload = $this->service->postPayload($post); + + expect($payload['user_id'])->toBeNull() + ->and($payload['author'])->toBeNull(); +}); + +test('postPayload keeps display fields when the social account is gone', function () { + $post = Post::factory()->published()->createQuietly([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + + $platform = PostPlatform::factory()->published()->recycle($post)->create([ + 'social_account_id' => null, + 'platform' => Platform::X, + 'content_type' => ContentType::XPost, + 'platform_name' => 'TryPost', + 'platform_username' => 'trypost', + 'platform_avatar' => null, + ]); + + $payload = $this->service->postPayload($post->fresh()); + + expect(data_get($payload, 'platforms.0.id'))->toBe($platform->id) + ->and(data_get($payload, 'platforms.0.social_account_id'))->toBeNull() + ->and(data_get($payload, 'platforms.0.social_account'))->toBeNull() + ->and(data_get($payload, 'platforms.0.display_name'))->toBe('TryPost') + ->and(data_get($payload, 'platforms.0.display_username'))->toBe('trypost'); +}); diff --git a/tests/Feature/Webhook/DispatchWebhookTest.php b/tests/Feature/Webhook/DispatchWebhookTest.php new file mode 100644 index 00000000..7de02c87 --- /dev/null +++ b/tests/Feature/Webhook/DispatchWebhookTest.php @@ -0,0 +1,637 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'events' => [WebhookEvent::PostPublished->value, WebhookEvent::PostFailed->value], + 'endpoint' => 'https://example.com/webhook', + ]); +}); + +test('webhook service dispatches job for matching event', function () { + Queue::fake(); + + app(WebhookService::class)->dispatch( + $this->workspace, + WebhookEvent::PostPublished, + ['id' => 'test-123'], + ); + + Queue::assertPushed(DispatchWebhook::class, function (DispatchWebhook $job) { + return $job->eventType === WebhookEvent::PostPublished->value + && $job->webhook->id === $this->webhook->id; + }); +}); + +test('webhook service does not dispatch job for unsubscribed event', function () { + Queue::fake(); + + app(WebhookService::class)->dispatch( + $this->workspace, + WebhookEvent::PostCreated, + ['id' => 'test-123'], + ); + + Queue::assertNotPushed(DispatchWebhook::class); +}); + +test('webhook service does not dispatch for wildcard subscriptions', function () { + Queue::fake(); + + $this->webhook->update(['events' => ['*']]); + + app(WebhookService::class)->dispatch( + $this->workspace, + WebhookEvent::PostPublished, + ['id' => 'test-123'], + ); + + Queue::assertNotPushed(DispatchWebhook::class); +}); + +test('webhook service does not dispatch for disabled webhooks', function () { + Queue::fake(); + + $this->webhook->update(['status' => Status::Disabled]); + + app(WebhookService::class)->dispatch( + $this->workspace, + WebhookEvent::PostPublished, + ['id' => 'test-123'], + ); + + Queue::assertNotPushed(DispatchWebhook::class); +}); + +test('webhook service does not dispatch for paused webhooks', function () { + Queue::fake(); + + $this->webhook->update([ + 'status' => Status::Paused, + 'events' => [WebhookEvent::PostPublished->value], + ]); + + app(WebhookService::class)->dispatch( + $this->workspace, + WebhookEvent::PostPublished, + ['id' => 'test-123'], + ); + + Queue::assertNotPushed(DispatchWebhook::class); +}); + +test('dispatch webhook job creates log and delivers successfully', function () { + Http::fake([ + 'example.com/webhook' => Http::response('OK', 200), + ]); + + $job = new DispatchWebhook( + $this->webhook, + WebhookEvent::PostPublished->value, + ['id' => 'test-123'], + ); + + app()->call([$job, 'handle']); + + $log = WebhookLog::query()->where('webhook_id', $this->webhook->id)->first(); + + expect($log)->not->toBeNull(); + expect($log->id)->toBe($job->logId); + expect(data_get($log->payload, 'id'))->toBe($log->id); + expect($log->event_type)->toBe(WebhookEvent::PostPublished->value); + expect($log->response_status)->toBe(200); + expect($log->delivered_at)->not->toBeNull(); + expect($log->failed_at)->toBeNull(); + expect($log->attempts)->toBe(1); + + $this->webhook->refresh(); + + expect($this->webhook->last_sent_at)->not->toBeNull(); +}); + +test('dispatch webhook job sends correct signature header', function () { + Http::fake([ + 'example.com/webhook' => Http::response('OK', 200), + ]); + + $job = new DispatchWebhook( + $this->webhook, + WebhookEvent::PostPublished->value, + ['id' => 'test-123'], + ); + + app()->call([$job, 'handle']); + + Http::assertSent(fn ($request) => $request->hasHeader('X-Webhook-Signature') + && $request->hasHeader('Content-Type', 'application/json')); +}); + +test('dispatch webhook job sends correct payload structure', function () { + Http::fake([ + 'example.com/webhook' => Http::response('OK', 200), + ]); + + $job = new DispatchWebhook( + $this->webhook, + WebhookEvent::PostPublished->value, + ['id' => 'test-123'], + ); + + app()->call([$job, 'handle']); + + Http::assertSent(function ($request) use ($job) { + $body = $request->data(); + + return $body['id'] === $job->logId + && $body['type'] === WebhookEvent::PostPublished->value + && $body['data']['id'] === 'test-123' + && isset($body['created_at']); + }); +}); + +test('dispatch webhook job sends the published post payload as data', function () { + Http::fake([ + 'example.com/webhook' => Http::response('OK', 200), + ]); + + $post = Post::factory()->published()->createQuietly([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'content' => '

Launch day. TryPost is live.

', + 'created_via' => CreatedVia::Web, + 'media' => [ + [ + 'id' => 'm_01', + 'path' => 'medias/9f2c-hero.jpg', + 'url' => 'https://cdn.example.com/medias/9f2c-hero.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'hero.jpg', + 'source' => 'unsplash', + 'meta' => ['alt_text' => 'Product screenshot on a laptop'], + ], + ], + ]); + + $label = WorkspaceLabel::factory()->recycle($this->workspace)->create([ + 'name' => 'Launch', + 'color' => '#7C3AED', + ]); + $post->labels()->attach($label); + + $account = SocialAccount::factory()->linkedin()->recycle($this->workspace)->create([ + 'display_name' => 'Paulo Castellano', + 'username' => 'paulocastellano', + 'avatar_url' => 'avatars/li.jpg', + ]); + PostPlatform::factory()->published()->recycle($post, $account)->create([ + 'platform' => Platform::LinkedIn, + 'content_type' => ContentType::LinkedInPost, + 'meta' => ['document_title' => 'TryPost launch deck'], + ]); + + $payload = app(WebhookService::class)->postPayload($post->fresh()); + + $job = new DispatchWebhook( + $this->webhook, + WebhookEvent::PostPublished->value, + $payload, + ); + + app()->call([$job, 'handle']); + + Http::assertSent(function ($request) use ($job, $payload) { + $body = $request->data(); + + return $body['id'] === $job->logId + && $body['type'] === WebhookEvent::PostPublished->value + && isset($body['created_at']) + && $body['data'] === $payload + && data_get($body, 'data.author.id') === $this->user->id + && data_get($body, 'data.author.name') === $this->user->name + && ! array_key_exists('email', data_get($body, 'data.author', [])) + && data_get($body, 'data.workspace.name') === $this->workspace->name + && data_get($body, 'data.labels.0.name') === 'Launch' + && data_get($body, 'data.media.0.type') === 'image' + && data_get($body, 'data.platforms.0.platform') === Platform::LinkedIn->value + && data_get($body, 'data.platforms.0.meta.document_title') === 'TryPost launch deck' + && ! array_key_exists('access_token', data_get($body, 'data.platforms.0.social_account', [])); + }); +}); + +test('webhook signature can be verified with signing secret', function () { + $capturedBody = null; + $capturedSignature = null; + + Http::fake(function ($request) use (&$capturedBody, &$capturedSignature) { + $capturedBody = $request->body(); + $capturedSignature = $request->header('X-Webhook-Signature')[0] ?? null; + + return Http::response('OK', 200); + }); + + $job = new DispatchWebhook( + $this->webhook, + WebhookEvent::PostPublished->value, + ['id' => 'test-123'], + ); + + app()->call([$job, 'handle']); + + $decoded = json_decode((string) $capturedBody, true); + $expectedSignature = hash_hmac('sha256', $capturedBody, $this->webhook->signing_secret); + + expect($decoded['id'])->toBe($job->logId) + ->and($capturedSignature)->toBe($expectedSignature); +}); + +test('unscheduling a post delivers the log id on the webhook envelope', function () { + Http::fake([ + 'example.com/webhook' => Http::response('OK', 200), + ]); + + $this->webhook->update([ + 'events' => [WebhookEvent::PostUnscheduled->value], + ]); + + $post = Post::factory()->scheduled()->createQuietly([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'content' => 'Was scheduled', + ]); + + UpdatePost::execute($this->workspace, $post, [ + 'status' => PostStatus::Draft->value, + ]); + + $log = WebhookLog::query()->where('webhook_id', $this->webhook->id)->first(); + + expect($log)->not->toBeNull() + ->and($log->event_type)->toBe(WebhookEvent::PostUnscheduled->value) + ->and(data_get($log->payload, 'id'))->toBe($log->id); + + Http::assertSent(function ($request) use ($log, $post) { + $body = $request->data(); + + return $body['id'] === $log->id + && $body['type'] === WebhookEvent::PostUnscheduled->value + && data_get($body, 'data.id') === $post->id + && data_get($body, 'data.status') === PostStatus::Draft->value; + }); +}); + +test('dispatch webhook job marks log as failed on http error', function () { + Http::fake([ + 'example.com/webhook' => Http::response('Server Error', 500), + ]); + + $job = new DispatchWebhook( + $this->webhook, + WebhookEvent::PostPublished->value, + ['id' => 'test-123'], + ); + + try { + app()->call([$job, 'handle']); + } catch (RuntimeException) { + // Expected + } + + $log = WebhookLog::query()->where('webhook_id', $this->webhook->id)->first(); + + expect($log->failed_at)->not->toBeNull(); + expect($log->delivered_at)->toBeNull(); + expect($log->response_status)->toBe(500); + expect($log->response_body)->toBe('Server Error'); + + $this->webhook->refresh(); + + expect($this->webhook->last_sent_at)->toBeNull(); +}); + +test('failed delivery does not overwrite last_sent_at', function () { + $sentAt = now()->subHour()->startOfSecond(); + $this->webhook->update(['last_sent_at' => $sentAt]); + + Http::fake([ + 'example.com/webhook' => Http::response('Server Error', 500), + ]); + + $job = new DispatchWebhook( + $this->webhook, + WebhookEvent::PostPublished->value, + ['id' => 'test-123'], + ); + + try { + app()->call([$job, 'handle']); + } catch (RuntimeException) { + } + + $this->webhook->refresh(); + + expect($this->webhook->last_sent_at?->equalTo($sentAt))->toBeTrue(); +}); + +test('dispatch webhook job marks log as failed on connection error', function () { + Http::fake([ + 'example.com/webhook' => function () { + throw new ConnectionException('Connection refused'); + }, + ]); + + $job = new DispatchWebhook( + $this->webhook, + WebhookEvent::PostPublished->value, + ['id' => 'test-123'], + ); + + try { + app()->call([$job, 'handle']); + } catch (Throwable) { + // Expected + } + + $log = WebhookLog::query()->where('webhook_id', $this->webhook->id)->first(); + + expect($log->failed_at)->not->toBeNull(); + + $this->webhook->refresh(); + + expect($this->webhook->last_sent_at)->toBeNull(); +}); + +test('dispatch webhook job rejects private endpoints', function () { + $this->webhook->update(['endpoint' => 'http://127.0.0.1/webhook']); + + $job = new DispatchWebhook( + $this->webhook, + WebhookEvent::PostPublished->value, + ['id' => 'test-123'], + ); + + try { + app()->call([$job, 'handle']); + } catch (RuntimeException) { + // Expected + } + + $log = WebhookLog::query()->where('webhook_id', $this->webhook->id)->first(); + + expect($log->failed_at)->not->toBeNull(); + expect($log->delivered_at)->toBeNull(); + + $this->webhook->refresh(); + + expect($this->webhook->last_sent_at)->toBeNull(); +}); + +test('dispatch webhook job skips delivery when the webhook is not enabled', function (Status $status) { + Http::fake([ + 'example.com/webhook' => Http::response('OK', 200), + ]); + + $this->webhook->update([ + 'status' => $status, + 'consecutive_failures' => $status === Status::Paused ? 5 : 0, + 'paused_at' => $status === Status::Paused ? now() : null, + ]); + + $job = new DispatchWebhook( + $this->webhook, + WebhookEvent::PostPublished->value, + ['id' => 'test-123'], + ); + + app()->call([$job, 'handle']); + + Http::assertNothingSent(); + expect(WebhookLog::query()->where('webhook_id', $this->webhook->id)->count())->toBe(0); + + $this->webhook->refresh(); + + expect($this->webhook->last_sent_at)->toBeNull(); +})->with([ + Status::Disabled, + Status::Paused, +]); + +test('dispatch webhook job delivers a forced replay when the webhook is not enabled', function () { + Http::fake([ + 'example.com/webhook' => Http::response('OK', 200), + ]); + + $this->webhook->update(['status' => Status::Disabled]); + + $job = new DispatchWebhook( + $this->webhook, + WebhookEvent::PostPublished->value, + ['id' => 'test-123'], + force: true, + ); + + app()->call([$job, 'handle']); + + Http::assertSentCount(1); + expect(WebhookLog::query()->where('webhook_id', $this->webhook->id)->first()?->delivered_at) + ->not->toBeNull(); +}); + +test('successful delivery resets consecutive failures', function () { + Http::fake([ + 'example.com/webhook' => Http::response('OK', 200), + ]); + + $this->webhook->update(['consecutive_failures' => 3]); + + $job = new DispatchWebhook( + $this->webhook, + WebhookEvent::PostPublished->value, + ['id' => 'test-123'], + ); + + app()->call([$job, 'handle']); + + $this->webhook->refresh(); + + expect($this->webhook->consecutive_failures)->toBe(0); +}); + +test('webhook service dispatches to multiple webhooks for same workspace', function () { + Queue::fake(); + + $webhook2 = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'events' => [WebhookEvent::PostPublished->value], + 'endpoint' => 'https://other.com/webhook', + ]); + + app(WebhookService::class)->dispatch( + $this->workspace, + WebhookEvent::PostPublished, + ['id' => 'test-123'], + ); + + Queue::assertPushed(DispatchWebhook::class, 2); + + Queue::assertPushed(DispatchWebhook::class, function (DispatchWebhook $job) { + return $job->webhook->id === $this->webhook->id; + }); + + Queue::assertPushed(DispatchWebhook::class, function (DispatchWebhook $job) use ($webhook2) { + return $job->webhook->id === $webhook2->id; + }); +}); + +test('dispatch webhook job runs on the webhooks queue', function () { + Queue::fake(); + + DispatchWebhook::dispatch($this->webhook, WebhookEvent::PostPublished->value, ['id' => 'test-123']); + + Queue::assertPushedOn('webhooks', DispatchWebhook::class); +}); + +test('dispatch webhook job keeps the same log after serialize retry', function () { + $sentIds = []; + + Http::fake(function ($request) use (&$sentIds) { + $sentIds[] = $request->data()['id'] ?? null; + + if (count($sentIds) === 1) { + return Http::response('Server Error', 500); + } + + return Http::response('OK', 200); + }); + + $job = new DispatchWebhook( + $this->webhook, + WebhookEvent::PostPublished->value, + ['id' => 'test-123'], + ); + + try { + app()->call([$job, 'handle']); + } catch (RuntimeException) { + } + + expect(WebhookLog::query()->where('webhook_id', $this->webhook->id)->count())->toBe(1); + + $retried = unserialize(serialize($job)); + + app()->call([$retried, 'handle']); + + expect($sentIds)->toHaveCount(2) + ->and($sentIds[0])->toBe($job->logId) + ->and($sentIds[1])->toBe($job->logId); + expect(WebhookLog::query()->where('webhook_id', $this->webhook->id)->count())->toBe(1); + expect(WebhookLog::query()->where('webhook_id', $this->webhook->id)->first()?->delivered_at) + ->not->toBeNull(); +}); + +test('dispatch webhook failed method does not increment when webhook is not enabled', function (Status $status) { + $this->webhook->update([ + 'status' => $status, + 'consecutive_failures' => $status === Status::Paused ? 5 : 0, + 'paused_at' => $status === Status::Paused ? now() : null, + ]); + + $job = new DispatchWebhook( + $this->webhook, + WebhookEvent::PostPublished->value, + ['id' => 'test-123'], + ); + + $job->failed(new RuntimeException('Connection timeout')); + + $this->webhook->refresh(); + + expect($this->webhook->consecutive_failures)->toBe($status === Status::Paused ? 5 : 0); +})->with([ + Status::Disabled, + Status::Paused, +]); + +test('dispatch webhook failed method increments consecutive failures', function () { + $job = new DispatchWebhook( + $this->webhook, + WebhookEvent::PostPublished->value, + ['id' => 'test-123'], + ); + + $job->failed(new RuntimeException('Connection timeout')); + + $this->webhook->refresh(); + + expect($this->webhook->consecutive_failures)->toBe(1); +}); + +test('dispatch webhook failed method pauses webhook and sends email after 5 failures', function () { + Mail::fake(); + + $this->webhook->update(['consecutive_failures' => 4]); + + $job = new DispatchWebhook( + $this->webhook, + WebhookEvent::PostPublished->value, + ['id' => 'test-123'], + ); + + $job->failed(new RuntimeException('Connection timeout')); + + $this->webhook->refresh(); + + expect($this->webhook->consecutive_failures)->toBe(5); + expect($this->webhook->status)->toBe(Status::Paused); + expect($this->webhook->paused_at)->not->toBeNull(); + + Mail::assertQueued(WebhookPausedMail::class, function (WebhookPausedMail $mail) { + return $mail->hasTo($this->user->email); + }); +}); + +test('dispatch webhook job broadcasts log updates', function () { + Event::fake([LogUpdated::class]); + + Http::fake([ + 'example.com/webhook' => Http::response('OK', 200), + ]); + + $job = new DispatchWebhook( + $this->webhook, + WebhookEvent::PostPublished->value, + ['id' => 'test-123'], + ); + + app()->call([$job, 'handle']); + + Event::assertDispatched(LogUpdated::class); +}); diff --git a/tests/Feature/Webhook/ReplayWebhookLogTest.php b/tests/Feature/Webhook/ReplayWebhookLogTest.php new file mode 100644 index 00000000..74739f7d --- /dev/null +++ b/tests/Feature/Webhook/ReplayWebhookLogTest.php @@ -0,0 +1,120 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->user->update(['current_workspace_id' => $this->workspace->id]); +}); + +test('authenticated users can replay a webhook log', function () { + Queue::fake(); + + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $log = WebhookLog::factory()->create([ + 'webhook_id' => $webhook->id, + 'event_type' => EventType::PostPublished->value, + 'payload' => [ + 'type' => EventType::PostPublished->value, + 'data' => ['id' => 'post-1'], + ], + ]); + + $this->actingAs($this->user) + ->post(route('app.webhooks.replay', [$webhook, $log])) + ->assertRedirect() + ->assertSessionHas('flash.banner', __('webhooks.flash.replayed')); + + Queue::assertPushed(DispatchWebhook::class, function (DispatchWebhook $job) use ($webhook) { + return $job->webhook->id === $webhook->id + && $job->eventType === EventType::PostPublished->value + && data_get($job->payload, 'id') === 'post-1' + && $job->force; + }); +}); + +test('workspace members cannot replay a webhook log', function () { + Queue::fake(); + + $member = User::factory()->create(['account_id' => $this->user->account_id]); + $this->workspace->members()->attach($member->id, ['role' => Role::Member->value]); + $member->update(['current_workspace_id' => $this->workspace->id]); + + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $log = WebhookLog::factory()->create([ + 'webhook_id' => $webhook->id, + ]); + + $this->actingAs($member) + ->post(route('app.webhooks.replay', [$webhook, $log])) + ->assertForbidden(); + + Queue::assertNothingPushed(); +}); + +test('users cannot replay webhook logs from other workspaces', function () { + $otherUser = User::factory()->create(); + $otherWorkspace = Workspace::factory()->create(['user_id' => $otherUser->id]); + $webhook = Webhook::factory()->create([ + 'workspace_id' => $otherWorkspace->id, + ]); + + $log = WebhookLog::factory()->create([ + 'webhook_id' => $webhook->id, + ]); + + $this->actingAs($this->user) + ->post(route('app.webhooks.replay', [$webhook, $log])) + ->assertForbidden(); +}); + +test('users cannot replay a webhook log belonging to a different webhook', function () { + Queue::fake(); + + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $otherWebhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $log = WebhookLog::factory()->create([ + 'webhook_id' => $otherWebhook->id, + ]); + + $this->actingAs($this->user) + ->post(route('app.webhooks.replay', [$webhook, $log])) + ->assertForbidden(); + + Queue::assertNothingPushed(); +}); + +test('guests cannot replay webhook logs', function () { + $webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $log = WebhookLog::factory()->create([ + 'webhook_id' => $webhook->id, + ]); + + $this->post(route('app.webhooks.replay', [$webhook, $log])) + ->assertRedirect(route('login')); +}); diff --git a/tests/Feature/Webhook/WebhookListenersTest.php b/tests/Feature/Webhook/WebhookListenersTest.php new file mode 100644 index 00000000..13332170 --- /dev/null +++ b/tests/Feature/Webhook/WebhookListenersTest.php @@ -0,0 +1,206 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + + Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'events' => array_column(EventType::cases(), 'value'), + ]); +}); + +test('SendPostCreatedWebhook dispatches a webhook', function () { + $post = Post::factory()->createQuietly([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + + app(SendPostCreatedWebhook::class)->handle(new PostCreated($post)); + + Queue::assertPushed(DispatchWebhook::class, function (DispatchWebhook $job) use ($post) { + return $job->eventType === EventType::PostCreated->value + && data_get($job->payload, 'id') === $post->id; + }); +}); + +test('SendPostDeletedWebhook dispatches a webhook with ids only', function () { + $post = Post::factory()->createQuietly([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + + app(SendPostDeletedWebhook::class)->handle(new PostDeleted($post->id, $this->workspace->id)); + + Queue::assertPushed(DispatchWebhook::class, function (DispatchWebhook $job) use ($post) { + return $job->eventType === EventType::PostDeleted->value + && $job->payload === [ + 'id' => $post->id, + 'workspace_id' => $this->workspace->id, + ]; + }); +}); + +test('SendPostDeletedWebhook skips when workspace is missing', function () { + app(SendPostDeletedWebhook::class)->handle(new PostDeleted( + '00000000-0000-4000-a000-000000000000', + '00000000-0000-4000-a000-000000000000', + )); + + Queue::assertNotPushed(DispatchWebhook::class); +}); + +test('SendPostStatusWebhook dispatches for known post statuses', function (PostStatus $status, EventType $event) { + $post = Post::factory()->createQuietly([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => $status, + ]); + + app(SendPostStatusWebhook::class)->handle(new PostStatusChanged($post)); + + Queue::assertPushed(DispatchWebhook::class, function (DispatchWebhook $job) use ($event) { + return $job->eventType === $event->value; + }); +})->with([ + [PostStatus::Scheduled, EventType::PostScheduled], + [PostStatus::Published, EventType::PostPublished], + [PostStatus::PartiallyPublished, EventType::PostPartiallyPublished], + [PostStatus::Failed, EventType::PostFailed], +]); + +test('SendPostStatusWebhook skips draft status', function () { + $post = Post::factory()->createQuietly([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + ]); + + app(SendPostStatusWebhook::class)->handle(new PostStatusChanged($post)); + + Queue::assertNotPushed(DispatchWebhook::class); +}); + +test('SendPostStatusWebhook dispatches post.unscheduled when a scheduled post becomes a draft', function () { + $post = Post::factory()->createQuietly([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + ]); + + app(SendPostStatusWebhook::class)->handle(new PostStatusChanged($post, PostStatus::Scheduled)); + + Queue::assertPushed(DispatchWebhook::class, function (DispatchWebhook $job) { + return $job->eventType === EventType::PostUnscheduled->value; + }); +}); + +test('SendPostStatusWebhook skips a draft that did not come from scheduled', function () { + $post = Post::factory()->createQuietly([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + ]); + + app(SendPostStatusWebhook::class)->handle(new PostStatusChanged($post, PostStatus::Failed)); + + Queue::assertNotPushed(DispatchWebhook::class); +}); + +test('creating a post dispatches PostCreated via the observer', function () { + Event::fake([PostCreated::class]); + + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + + Event::assertDispatched( + PostCreated::class, + fn (PostCreated $event) => $event->post->id === $post->id, + ); +}); + +test('changing post status dispatches PostStatusChanged via the observer', function () { + Event::fake([PostStatusChanged::class]); + + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + ]); + + Event::fake([PostStatusChanged::class]); + + $post->update(['status' => PostStatus::Published]); + + Event::assertDispatched( + PostStatusChanged::class, + fn (PostStatusChanged $event) => $event->post->id === $post->id, + ); +}); + +test('unscheduling dispatches PostStatusChanged with the previous scheduled status', function () { + $post = Post::factory()->scheduled()->createQuietly([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + + Event::fake([PostStatusChanged::class]); + + $post->update(['status' => PostStatus::Draft]); + + Event::assertDispatched( + PostStatusChanged::class, + fn (PostStatusChanged $event) => $event->post->id === $post->id + && $event->previousStatus === PostStatus::Scheduled, + ); +}); + +test('deleting a post dispatches the deleted webhook', function () { + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + + DeletePost::execute($post); + + Queue::assertPushed(DispatchWebhook::class, function (DispatchWebhook $job) use ($post) { + return $job->eventType === EventType::PostDeleted->value + && data_get($job->payload, 'id') === $post->id; + }); +}); + +test('post created listener is wired via auto-discovery', function () { + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + + PostCreated::dispatch($post); + + Queue::assertPushed(DispatchWebhook::class, function (DispatchWebhook $job) { + return $job->eventType === EventType::PostCreated->value; + }); +}); diff --git a/tests/Feature/Webhook/WebhookPostLifecycleTest.php b/tests/Feature/Webhook/WebhookPostLifecycleTest.php new file mode 100644 index 00000000..961fe6a2 --- /dev/null +++ b/tests/Feature/Webhook/WebhookPostLifecycleTest.php @@ -0,0 +1,405 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); +}); + +/** + * @param list $events + */ +function subscribeToWebhook(Workspace $workspace, array $events): Webhook +{ + return Webhook::factory()->create([ + 'workspace_id' => $workspace->id, + 'events' => array_map(fn (EventType $event): string => $event->value, $events), + ]); +} + +test('webhook listeners are registered for the post lifecycle events', function () { + Event::fake(); + + Event::assertListening(PostCreated::class, SendPostCreatedWebhook::class); + Event::assertListening(PostStatusChanged::class, SendPostStatusWebhook::class); + Event::assertListening(PostDeleted::class, SendPostDeletedWebhook::class); +}); + +test('creating a post through CreatePost queues a post.created webhook', function () { + subscribeToWebhook($this->workspace, [EventType::PostCreated]); + + $post = CreatePost::execute($this->workspace, $this->user, [ + 'content' => 'Hello from webhooks', + 'created_via' => CreatedVia::Web, + ]); + + Queue::assertPushed(DispatchWebhook::class, function (DispatchWebhook $job) use ($post) { + return $job->eventType === EventType::PostCreated->value + && data_get($job->payload, 'id') === $post->id + && data_get($job->payload, 'workspace_id') === $this->workspace->id + && data_get($job->payload, 'status') === PostStatus::Draft->value; + }); +}); + +test('creating a post through CreatePost includes labels and platforms in the payload', function () { + subscribeToWebhook($this->workspace, [EventType::PostCreated]); + + $label = WorkspaceLabel::factory()->recycle($this->workspace)->create([ + 'name' => 'Launch', + 'color' => '#7C3AED', + ]); + $account = SocialAccount::factory()->linkedin()->recycle($this->workspace)->create([ + 'display_name' => 'Paulo Castellano', + 'username' => 'paulocastellano', + ]); + + $post = CreatePost::execute($this->workspace, $this->user, [ + 'content' => 'Hello from webhooks', + 'created_via' => CreatedVia::Web, + 'label_ids' => [$label->id], + 'platforms' => [[ + 'social_account_id' => $account->id, + 'content_type' => ContentType::LinkedInPost->value, + 'meta' => ['document_title' => 'TryPost launch deck'], + ]], + ]); + + Queue::assertPushed(DispatchWebhook::class, function (DispatchWebhook $job) use ($post, $label, $account) { + return $job->eventType === EventType::PostCreated->value + && data_get($job->payload, 'id') === $post->id + && data_get($job->payload, 'labels.0.id') === $label->id + && data_get($job->payload, 'labels.0.name') === 'Launch' + && data_get($job->payload, 'platforms.0.social_account_id') === $account->id + && data_get($job->payload, 'platforms.0.enabled') === true + && data_get($job->payload, 'platforms.0.meta.document_title') === 'TryPost launch deck'; + }); +}); + +test('scheduling a post through UpdatePost includes labels and platforms from the same save', function () { + subscribeToWebhook($this->workspace, [EventType::PostScheduled]); + + $post = Post::factory()->createQuietly([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + 'content' => 'Ready to schedule', + ]); + + $label = WorkspaceLabel::factory()->recycle($this->workspace)->create([ + 'name' => 'Launch', + 'color' => '#7C3AED', + ]); + $account = SocialAccount::factory()->linkedin()->recycle($this->workspace)->create(); + $platform = PostPlatform::factory()->recycle($post, $account)->create([ + 'platform' => Platform::LinkedIn, + 'content_type' => ContentType::LinkedInPost, + 'enabled' => false, + 'meta' => [], + ]); + + UpdatePost::execute($this->workspace, $post, [ + 'status' => PostStatus::Scheduled->value, + 'scheduled_at' => now()->addDay()->toIso8601String(), + 'label_ids' => [$label->id], + 'platforms' => [[ + 'id' => $platform->id, + 'content_type' => ContentType::LinkedInPost->value, + 'meta' => ['document_title' => 'TryPost launch deck'], + ]], + ]); + + Queue::assertPushed(DispatchWebhook::class, function (DispatchWebhook $job) use ($post, $label, $platform) { + return $job->eventType === EventType::PostScheduled->value + && data_get($job->payload, 'id') === $post->id + && data_get($job->payload, 'status') === PostStatus::Scheduled->value + && data_get($job->payload, 'labels.0.id') === $label->id + && data_get($job->payload, 'labels.0.name') === 'Launch' + && data_get($job->payload, 'platforms.0.id') === $platform->id + && data_get($job->payload, 'platforms.0.enabled') === true + && data_get($job->payload, 'platforms.0.meta.document_title') === 'TryPost launch deck'; + }); +}); + +test('creating a draft does not queue status webhooks', function () { + subscribeToWebhook($this->workspace, EventType::cases()); + + CreatePost::execute($this->workspace, $this->user, [ + 'content' => 'Draft only', + 'created_via' => CreatedVia::Web, + ]); + + Queue::assertPushed(DispatchWebhook::class, 1); + Queue::assertPushed(DispatchWebhook::class, fn (DispatchWebhook $job) => $job->eventType === EventType::PostCreated->value); +}); + +test('unscheduling a post through UpdatePost queues a post.unscheduled webhook', function () { + subscribeToWebhook($this->workspace, [EventType::PostUnscheduled]); + + $post = Post::factory()->createQuietly([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Scheduled, + 'scheduled_at' => now()->addDay(), + 'content' => 'Was scheduled', + ]); + + UpdatePost::execute($this->workspace, $post, [ + 'status' => PostStatus::Draft->value, + ]); + + Queue::assertPushed(DispatchWebhook::class, function (DispatchWebhook $job) use ($post) { + return $job->eventType === EventType::PostUnscheduled->value + && data_get($job->payload, 'id') === $post->id + && data_get($job->payload, 'status') === PostStatus::Draft->value; + }); +}); + +test('saving a draft that was never scheduled does not queue post.unscheduled', function () { + subscribeToWebhook($this->workspace, [EventType::PostUnscheduled]); + + $post = Post::factory()->createQuietly([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + 'content' => 'Still a draft', + ]); + + UpdatePost::execute($this->workspace, $post, [ + 'status' => PostStatus::Draft->value, + 'content' => 'Still a draft, edited', + ]); + + Queue::assertNotPushed(DispatchWebhook::class); +}); + +test('moving a failed post back to draft does not queue post.unscheduled', function () { + subscribeToWebhook($this->workspace, [EventType::PostUnscheduled]); + + $post = Post::factory()->failed()->createQuietly([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + + $post->update(['status' => PostStatus::Draft]); + + Queue::assertNotPushed(DispatchWebhook::class); +}); + +test('changing post status through the observer queues the matching webhook', function (PostStatus $status, EventType $event) { + subscribeToWebhook($this->workspace, [$event]); + + $post = Post::factory()->createQuietly([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + ]); + + match ($status) { + PostStatus::Scheduled => $post->update([ + 'status' => PostStatus::Scheduled, + 'scheduled_at' => now()->addDay(), + ]), + PostStatus::Published => $post->markAsPublished(), + PostStatus::PartiallyPublished => $post->markAsPartiallyPublished(), + PostStatus::Failed => $post->markAsFailed(), + default => $post->update(['status' => $status]), + }; + + Queue::assertPushed(DispatchWebhook::class, function (DispatchWebhook $job) use ($post, $event) { + return $job->eventType === $event->value + && data_get($job->payload, 'id') === $post->id + && data_get($job->payload, 'author.id') === $this->user->id + && data_get($job->payload, 'workspace.id') === $this->workspace->id + && array_key_exists('labels', $job->payload) + && array_key_exists('media', $job->payload) + && array_key_exists('platforms', $job->payload); + }); +})->with([ + [PostStatus::Scheduled, EventType::PostScheduled], + [PostStatus::Published, EventType::PostPublished], + [PostStatus::PartiallyPublished, EventType::PostPartiallyPublished], + [PostStatus::Failed, EventType::PostFailed], +]); + +test('publishing a post queues the full webhook payload', function () { + subscribeToWebhook($this->workspace, [EventType::PostPublished]); + + $post = Post::factory()->createQuietly([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + 'content' => '

Launch day. TryPost is live.

', + 'created_via' => CreatedVia::Web, + 'media' => [ + [ + 'id' => 'm_01', + 'path' => 'medias/9f2c-hero.jpg', + 'url' => 'https://cdn.example.com/medias/9f2c-hero.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'hero.jpg', + 'source' => 'unsplash', + 'meta' => ['alt_text' => 'Product screenshot on a laptop'], + ], + ], + ]); + + $label = WorkspaceLabel::factory()->recycle($this->workspace)->create([ + 'name' => 'Launch', + 'color' => '#7C3AED', + ]); + $post->labels()->attach($label); + + $account = SocialAccount::factory()->linkedin()->recycle($this->workspace)->create([ + 'display_name' => 'Paulo Castellano', + 'username' => 'paulocastellano', + 'avatar_url' => 'avatars/li.jpg', + ]); + $platform = PostPlatform::factory()->published()->recycle($post, $account)->create([ + 'platform' => Platform::LinkedIn, + 'content_type' => ContentType::LinkedInPost, + 'meta' => ['document_title' => 'TryPost launch deck'], + ]); + + $post->markAsPublished(); + + $expected = app(WebhookService::class)->postPayload($post->fresh()); + + Queue::assertPushed(DispatchWebhook::class, function (DispatchWebhook $job) use ($expected, $platform) { + return $job->eventType === EventType::PostPublished->value + && $job->payload['id'] === $expected['id'] + && $job->payload['status'] === PostStatus::Published->value + && $job->payload['author'] === $expected['author'] + && $job->payload['workspace'] === $expected['workspace'] + && $job->payload['labels'] === $expected['labels'] + && $job->payload['media'] === $expected['media'] + && $job->payload['platforms'] === $expected['platforms'] + && data_get($job->payload, 'platforms.0.id') === $platform->id; + }); +}); + +test('marking a post as publishing does not queue a webhook', function () { + subscribeToWebhook($this->workspace, EventType::cases()); + + $post = Post::factory()->createQuietly([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + ]); + + $post->markAsPublishing(); + + Queue::assertNotPushed(DispatchWebhook::class); +}); + +test('deleting a post through DeletePost queues a post.deleted webhook', function () { + subscribeToWebhook($this->workspace, [EventType::PostDeleted]); + + $post = Post::factory()->createQuietly([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + + DeletePost::execute($post); + + Queue::assertPushed(DispatchWebhook::class, function (DispatchWebhook $job) use ($post) { + return $job->eventType === EventType::PostDeleted->value + && $job->payload === [ + 'id' => $post->id, + 'workspace_id' => $this->workspace->id, + ]; + }); +}); + +test('a paused webhook is not queued when a post is created', function () { + Webhook::factory()->paused()->create([ + 'workspace_id' => $this->workspace->id, + 'events' => [EventType::PostCreated->value], + ]); + + CreatePost::execute($this->workspace, $this->user, [ + 'content' => 'Should not notify', + 'created_via' => CreatedVia::Web, + ]); + + Queue::assertNotPushed(DispatchWebhook::class); +}); + +test('a disabled webhook is not queued when a post is created', function () { + Webhook::factory()->disabled()->create([ + 'workspace_id' => $this->workspace->id, + 'events' => [EventType::PostCreated->value], + ]); + + CreatePost::execute($this->workspace, $this->user, [ + 'content' => 'Should not notify', + 'created_via' => CreatedVia::Web, + ]); + + Queue::assertNotPushed(DispatchWebhook::class); +}); + +test('a webhook subscribed only to other events is not queued on create', function () { + subscribeToWebhook($this->workspace, [EventType::PostPublished, EventType::PostFailed]); + + CreatePost::execute($this->workspace, $this->user, [ + 'content' => 'Created but unpublished', + 'created_via' => CreatedVia::Web, + ]); + + Queue::assertNotPushed(DispatchWebhook::class); +}); + +test('a webhook from another workspace is not queued', function () { + $otherWorkspace = Workspace::factory()->create(); + subscribeToWebhook($otherWorkspace, EventType::cases()); + + CreatePost::execute($this->workspace, $this->user, [ + 'content' => 'Other workspace should not see this', + 'created_via' => CreatedVia::Web, + ]); + + Queue::assertNotPushed(DispatchWebhook::class); +}); + +test('creating a post through the observer without Event::fake still queues the job', function () { + subscribeToWebhook($this->workspace, [EventType::PostCreated]); + + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + + Queue::assertPushed(DispatchWebhook::class, function (DispatchWebhook $job) use ($post) { + return $job->eventType === EventType::PostCreated->value + && data_get($job->payload, 'id') === $post->id; + }); +}); diff --git a/tests/Unit/Automation/AutomationConfigValidatorTest.php b/tests/Unit/Automation/AutomationConfigValidatorTest.php index 36f2d987..5be2fd24 100644 --- a/tests/Unit/Automation/AutomationConfigValidatorTest.php +++ b/tests/Unit/Automation/AutomationConfigValidatorTest.php @@ -3,54 +3,21 @@ declare(strict_types=1); use App\Services\Automation\AutomationConfigValidator; -use App\Services\Automation\WebhookNodeValidator; -it('treats empty and literal-null webhook payloads as valid', function (string $template) { - $issue = app(WebhookNodeValidator::class)->issueFor(['payload_template' => $template]); - - expect($issue)->toBeNull(); -})->with(['', ' ', 'null']); - -it('accepts a webhook payload whose placeholders are quoted valid JSON', function () { - $issue = app(WebhookNodeValidator::class)->issueFor([ - 'payload_template' => '{"title": "{{fetched.title}}"}', - ]); - - expect($issue)->toBeNull(); -}); - -it('rejects a webhook payload that is not valid JSON', function () { - $issue = app(WebhookNodeValidator::class)->issueFor([ - 'payload_template' => '{"title": {{fetched.title}}}', - ]); - - expect($issue)->toBe(__('automations.errors.webhook_invalid_payload_json')); -}); - -it('reports an issue per invalid node, keyed to its field and index', function () { - $nodes = [ +it('reports no issues for nodes that have no dedicated config validator', function () { + $issues = app(AutomationConfigValidator::class)->issues([ ['type' => 'trigger', 'data' => ['trigger_type' => 'schedule']], - ['type' => 'webhook', 'data' => ['payload_template' => 'not json']], - ['type' => 'webhook', 'data' => ['payload_template' => '{"ok": "{{x}}"}']], - ['type' => 'webhook', 'data' => ['payload_template' => '{bad']], - ]; + ['type' => 'http_request', 'data' => ['url' => 'https://example.com', 'method' => 'POST']], + ['type' => 'end', 'data' => []], + ]); - $issues = app(AutomationConfigValidator::class)->issues($nodes); - - expect($issues)->toHaveCount(2) - ->and($issues[0])->toMatchArray(['node_index' => 1, 'field' => 'payload_template']) - ->and($issues[1]['node_index'])->toBe(3); + expect($issues)->toBe([]); }); -it('returns the first issue message and null when every node is valid', function () { - $validator = app(AutomationConfigValidator::class); +it('returns null when every node is valid', function () { + $message = app(AutomationConfigValidator::class)->firstMessage([ + ['type' => 'http_request', 'data' => ['url' => 'https://example.com', 'method' => 'GET']], + ]); - $invalid = [ - ['type' => 'webhook', 'data' => ['payload_template' => 'nope']], - ['type' => 'webhook', 'data' => ['payload_template' => 'also nope']], - ]; - $valid = [['type' => 'webhook', 'data' => ['payload_template' => '{}']]]; - - expect($validator->firstMessage($invalid))->toBe(__('automations.errors.webhook_invalid_payload_json')) - ->and($validator->firstMessage($valid))->toBeNull(); + expect($message)->toBeNull(); }); diff --git a/tests/Unit/Broadcasting/WebhookLogChannelTest.php b/tests/Unit/Broadcasting/WebhookLogChannelTest.php new file mode 100644 index 00000000..c632f5c6 --- /dev/null +++ b/tests/Unit/Broadcasting/WebhookLogChannelTest.php @@ -0,0 +1,72 @@ +create(); + $workspace = Workspace::factory()->create(['user_id' => $user->id]); + $user->update(['current_workspace_id' => $workspace->id]); + $user->refresh(); + + $webhook = Webhook::factory()->create(['workspace_id' => $workspace->id]); + + expect((new WebhookLogChannel)->join($user, $webhook))->toBeTrue(); +}); + +test('webhook log channel allows a workspace admin', function () { + $owner = User::factory()->create(); + $workspace = Workspace::factory()->create(['user_id' => $owner->id]); + $admin = User::factory()->create(['account_id' => $owner->account_id]); + $workspace->members()->attach($admin->id, ['role' => Role::Admin->value]); + $admin->update(['current_workspace_id' => $workspace->id]); + $admin->refresh(); + + $webhook = Webhook::factory()->create(['workspace_id' => $workspace->id]); + + expect((new WebhookLogChannel)->join($admin, $webhook))->toBeTrue(); +}); + +test('webhook log channel denies a member', function () { + $owner = User::factory()->create(); + $workspace = Workspace::factory()->create(['user_id' => $owner->id]); + $member = User::factory()->create(['account_id' => $owner->account_id]); + $workspace->members()->attach($member->id, ['role' => Role::Member->value]); + $member->update(['current_workspace_id' => $workspace->id]); + $member->refresh(); + + $webhook = Webhook::factory()->create(['workspace_id' => $workspace->id]); + + expect((new WebhookLogChannel)->join($member, $webhook))->toBeFalse(); +}); + +test('webhook log channel denies a viewer', function () { + $owner = User::factory()->create(); + $workspace = Workspace::factory()->create(['user_id' => $owner->id]); + $viewer = User::factory()->create(['account_id' => $owner->account_id]); + $workspace->members()->attach($viewer->id, ['role' => Role::Viewer->value]); + $viewer->update(['current_workspace_id' => $workspace->id]); + $viewer->refresh(); + + $webhook = Webhook::factory()->create(['workspace_id' => $workspace->id]); + + expect((new WebhookLogChannel)->join($viewer, $webhook))->toBeFalse(); +}); + +test('webhook log channel denies a user from another workspace', function () { + $owner = User::factory()->create(); + $workspace = Workspace::factory()->create(['user_id' => $owner->id]); + $webhook = Webhook::factory()->create(['workspace_id' => $workspace->id]); + + $outsider = User::factory()->create(); + $otherWorkspace = Workspace::factory()->create(['user_id' => $outsider->id]); + $outsider->update(['current_workspace_id' => $otherWorkspace->id]); + $outsider->refresh(); + + expect((new WebhookLogChannel)->join($outsider, $webhook))->toBeFalse(); +}); diff --git a/tests/Unit/DataTransferObjects/MediaItemTest.php b/tests/Unit/DataTransferObjects/MediaItemTest.php index e6953ad2..ee6fdfac 100644 --- a/tests/Unit/DataTransferObjects/MediaItemTest.php +++ b/tests/Unit/DataTransferObjects/MediaItemTest.php @@ -12,6 +12,16 @@ expect(MediaItem::fromArray(['path' => 'archive.zip'])->mime_type)->toBeNull(); }); +test('fromArray coerces a numeric media id to a string', function () { + $item = MediaItem::fromArray([ + 'id' => 42, + 'path' => 'generated.png', + 'url' => 'https://cdn.example.com/generated.png', + ]); + + expect($item->id)->toBe('42'); +}); + test('fromArray keeps an explicit mime type over the extension', function () { $item = MediaItem::fromArray(['path' => 'thing.png', 'mime_type' => 'video/mp4']); diff --git a/tests/Unit/Enums/WebhookEventTypeTest.php b/tests/Unit/Enums/WebhookEventTypeTest.php new file mode 100644 index 00000000..c3e83b78 --- /dev/null +++ b/tests/Unit/Enums/WebhookEventTypeTest.php @@ -0,0 +1,33 @@ +value)->toBe($value); +})->with([ + [EventType::PostCreated, 'post.created'], + [EventType::PostScheduled, 'post.scheduled'], + [EventType::PostUnscheduled, 'post.unscheduled'], + [EventType::PostPublished, 'post.published'], + [EventType::PostPartiallyPublished, 'post.partially_published'], + [EventType::PostFailed, 'post.failed'], + [EventType::PostDeleted, 'post.deleted'], +]); + +test('fromPostStatus maps publishable statuses and ignores the rest', function (PostStatus $status, ?EventType $event, ?PostStatus $previous = null) { + expect(EventType::fromPostStatus($status, $previous))->toBe($event); +})->with([ + [PostStatus::Scheduled, EventType::PostScheduled], + [PostStatus::Published, EventType::PostPublished], + [PostStatus::PartiallyPublished, EventType::PostPartiallyPublished], + [PostStatus::Failed, EventType::PostFailed], + [PostStatus::Draft, null], + [PostStatus::Draft, EventType::PostUnscheduled, PostStatus::Scheduled], + [PostStatus::Draft, null, PostStatus::Failed], + [PostStatus::Draft, null, PostStatus::Publishing], + [PostStatus::Draft, null, PostStatus::Published], + [PostStatus::Publishing, null], +]); diff --git a/tests/Unit/Mail/WebhookPausedMailTest.php b/tests/Unit/Mail/WebhookPausedMailTest.php new file mode 100644 index 00000000..04a37f2b --- /dev/null +++ b/tests/Unit/Mail/WebhookPausedMailTest.php @@ -0,0 +1,60 @@ +create([ + 'endpoint' => 'https://example.com/hooks', + ]); + + $mail = new WebhookPausedMail($webhook); + + expect($mail->envelope()->subject)->toBe(__('webhooks.mail.paused_subject', [ + 'endpoint' => 'https://example.com/hooks', + ])); +}); + +test('webhook paused mail has the translated content', function () { + $webhook = Webhook::factory()->create([ + 'endpoint' => 'https://example.com/hooks', + ]); + + $mail = new WebhookPausedMail($webhook); + $content = $mail->content(); + + expect($content->view)->toBe('mail.webhook-paused') + ->and($content->with['title'])->toBe(__('webhooks.mail.paused_title')) + ->and($content->with['previewText'])->toBe(__('webhooks.mail.paused_preview')) + ->and($content->with['body'])->toBe(__('webhooks.mail.paused_body', [ + 'endpoint' => 'https://example.com/hooks', + ])) + ->and($content->with['buttonText'])->toBe(__('webhooks.mail.paused_cta')) + ->and($content->with['url'])->toBe(route('app.webhooks.show', $webhook)); +}); + +test('webhook paused mail is queueable', function () { + $webhook = Webhook::factory()->create(); + + expect(new WebhookPausedMail($webhook))->toBeInstanceOf(ShouldQueue::class); +}); + +test('webhook paused mail renders the maizzle layout', function () { + $webhook = Webhook::factory()->create([ + 'endpoint' => 'https://example.com/hooks', + ]); + + $mail = new WebhookPausedMail($webhook); + + $mail->assertSeeInHtml(__('webhooks.mail.paused_title')); + $mail->assertSeeInHtml(__('webhooks.mail.paused_body', [ + 'endpoint' => 'https://example.com/hooks', + ])); + $mail->assertSeeInHtml(__('webhooks.mail.paused_cta')); + $mail->assertSeeInHtml(route('app.webhooks.show', $webhook)); + $mail->assertSeeInHtml('Manage notifications'); + $mail->assertSeeInHtml(route('app.notifications.preferences')); +}); diff --git a/tests/Unit/Policies/WorkspacePolicyTest.php b/tests/Unit/Policies/WorkspacePolicyTest.php index 6396f9df..d0564f85 100644 --- a/tests/Unit/Policies/WorkspacePolicyTest.php +++ b/tests/Unit/Policies/WorkspacePolicyTest.php @@ -243,6 +243,30 @@ expect($this->policy->manageAccounts($member, $workspace))->toBeFalse(); }); +test('account owner and workspace admin can manage webhooks', function () { + $account = Account::factory()->create(); + $owner = User::factory()->create([ + 'account_id' => $account->id, + ]); + $account->update(['owner_id' => $owner->id]); + $admin = User::factory()->create([ + 'account_id' => $account->id, + ]); + $member = User::factory()->create([ + 'account_id' => $account->id, + ]); + $workspace = Workspace::factory()->create([ + 'account_id' => $account->id, + 'user_id' => $owner->id, + ]); + $workspace->members()->attach($admin->id, ['role' => Role::Admin->value]); + $workspace->members()->attach($member->id, ['role' => Role::Member->value]); + + expect($this->policy->manageWebhooks($owner, $workspace))->toBeTrue(); + expect($this->policy->manageWebhooks($admin, $workspace))->toBeTrue(); + expect($this->policy->manageWebhooks($member, $workspace))->toBeFalse(); +}); + test('account owner and workspace member can create post', function () { $account = Account::factory()->create(); $owner = User::factory()->create([ @@ -281,6 +305,7 @@ expect($this->policy->view($viewer, $workspace))->toBeTrue(); expect($this->policy->createPost($viewer, $workspace))->toBeFalse(); + expect($this->policy->manageWebhooks($viewer, $workspace))->toBeFalse(); expect($this->policy->manageTeam($viewer, $workspace))->toBeFalse(); expect($this->policy->inviteMember($viewer, $workspace))->toBeFalse(); }); diff --git a/tests/Unit/WebhookTranslationsTest.php b/tests/Unit/WebhookTranslationsTest.php new file mode 100644 index 00000000..67377b34 --- /dev/null +++ b/tests/Unit/WebhookTranslationsTest.php @@ -0,0 +1,106 @@ + + */ +function webhookTranslationKeys(array $translations, string $prefix = ''): array +{ + $keys = []; + + foreach ($translations as $key => $value) { + $path = $prefix === '' ? (string) $key : "{$prefix}.{$key}"; + + if (is_array($value)) { + $keys = [...$keys, ...webhookTranslationKeys($value, $path)]; + + continue; + } + + $keys[] = $path; + } + + return $keys; +} + +test('every locale has the same webhook translation keys as english', function () { + $english = webhookTranslationKeys(require lang_path('en/webhooks.php')); + + $locales = collect(glob(lang_path('*')) ?: []) + ->filter(fn (string $path) => is_dir($path) && is_file("{$path}/webhooks.php")) + ->map(fn (string $path) => basename($path)) + ->reject(fn (string $locale) => $locale === 'en') + ->values() + ->all(); + + expect($locales)->not->toBeEmpty(); + + foreach ($locales as $locale) { + expect(webhookTranslationKeys(require lang_path("{$locale}/webhooks.php"))) + ->toEqual($english, "Missing or extra webhook keys in {$locale}"); + } +}); + +test('every webhook event type has a translated label', function () { + $originalLocale = app()->getLocale(); + + $locales = collect(glob(lang_path('*')) ?: []) + ->filter(fn (string $path) => is_dir($path) && is_file("{$path}/webhooks.php")) + ->map(fn (string $path) => basename($path)) + ->values() + ->all(); + + foreach ($locales as $locale) { + app()->setLocale($locale); + + foreach (EventType::cases() as $event) { + $key = 'webhooks.events.'.str_replace('.', '_', $event->value); + + expect(__($key))->not->toBe($key, "Missing {$key} in {$locale}"); + } + } + + app()->setLocale($originalLocale); +}); + +test('http status reason keys resolve in php', function () { + expect(__('webhooks.http_reasons.200'))->toBe('OK') + ->and(__('webhooks.http_reasons.unknown'))->toBe('Unknown') + ->and(__('webhooks.show.status_code', ['code' => '404', 'reason' => 'Not Found']))->toBe('404 - Not Found') + ->and(__('webhooks.delete.cancel'))->toBe('Cancel'); +}); + +test('non-english locales translate webhook chrome instead of leaving english copy', function () { + $english = require lang_path('en/webhooks.php'); + + $mustDiffer = [ + 'new', + 'empty_title', + 'never', + 'create.cancel', + 'flash.created', + 'actions.delete', + 'mail.paused_cta', + 'show.empty_title', + 'errors.endpoint_not_allowed', + ]; + + $locales = collect(glob(lang_path('*')) ?: []) + ->filter(fn (string $path) => is_dir($path) && is_file("{$path}/webhooks.php")) + ->map(fn (string $path) => basename($path)) + ->reject(fn (string $locale) => $locale === 'en') + ->values() + ->all(); + + foreach ($locales as $locale) { + $translations = require lang_path("{$locale}/webhooks.php"); + + foreach ($mustDiffer as $key) { + expect(data_get($translations, $key)) + ->not->toBe(data_get($english, $key), "{$key} is still English in {$locale}"); + } + } +});