ses-dashboard 0.1.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +226 -31
- data/app/assets/javascripts/ses_dashboard/application.js +263 -0
- data/app/assets/stylesheets/ses_dashboard/application.css +65 -0
- data/app/controllers/ses_dashboard/projects_controller.rb +1 -1
- data/app/controllers/ses_dashboard/webhooks_controller.rb +22 -1
- data/app/models/ses_dashboard/project.rb +48 -0
- data/app/views/ses_dashboard/projects/_form.html.erb +16 -0
- data/db/migrate/20240101000001_create_ses_dashboard_projects.rb +7 -1
- data/db/migrate/20240101000002_create_ses_dashboard_emails.rb +7 -1
- data/db/migrate/20240101000003_create_ses_dashboard_email_events.rb +7 -1
- data/db/migrate/20240101000004_add_webhook_forwards_to_ses_dashboard_projects.rb +11 -0
- data/lib/ses/dashboard.rb +1 -0
- data/lib/ses_dashboard/client.rb +3 -3
- data/lib/ses_dashboard/engine.rb +0 -7
- data/lib/ses_dashboard/forward_rule.rb +72 -0
- data/lib/ses_dashboard/sns_signature_verifier.rb +92 -0
- data/lib/ses_dashboard/version.rb +1 -1
- data/lib/ses_dashboard/webhook_forwarder.rb +105 -0
- data/lib/ses_dashboard/webhook_processor.rb +14 -2
- data/lib/ses_dashboard.rb +23 -11
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9efac9cf44528ae2052092bfcef569f312d9da609f23039459515ddc54a67072
|
|
4
|
+
data.tar.gz: 387467a34a67ec49a15241222b6a9dc077abcda5c545c872737e152b22d82905
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 771334e8e2d8d0459d1c462022a69608958753d67845e5100bea2365fbfc9e47e1bc9f3c49e5befd804e456a5de22b3b0c23f98f6a86fe3cf4d223222eac9dbd
|
|
7
|
+
data.tar.gz: 922d8c07c25a5beac7beda1ec6bcecbb483c852f2bbe031aa65003ff181982c22a7b052c8d5e0efb5cdfda99d3339a1ccd113fc3bc4a2481dbe55ac62b656ea7
|
data/README.md
CHANGED
|
@@ -1,7 +1,51 @@
|
|
|
1
1
|
# SES Dashboard
|
|
2
|
+
[](https://codecov.io/gh/antodoms/ses_dashboard)
|
|
3
|
+
[](https://badge.fury.io/rb/ses-dashboard)
|
|
2
4
|
|
|
3
5
|
A mountable Rails engine that provides a real-time dashboard for Amazon SES, tracking email delivery, bounces, complaints, opens, and clicks via SNS webhooks.
|
|
4
6
|
|
|
7
|
+
## Screenshots
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
<table>
|
|
12
|
+
<tr>
|
|
13
|
+
<td><img src="docs/screenshots/project-dashboard-view-page.png" alt="Project Dashboard" /></td>
|
|
14
|
+
<td><img src="docs/screenshots/project-activity-page.png" alt="Activity Log" /></td>
|
|
15
|
+
</tr>
|
|
16
|
+
<tr>
|
|
17
|
+
<td align="center"><em>Per-project stats & email volume chart</em></td>
|
|
18
|
+
<td align="center"><em>Paginated activity log with search & export</em></td>
|
|
19
|
+
</tr>
|
|
20
|
+
<tr>
|
|
21
|
+
<td colspan="2"><img src="docs/screenshots/email-details.png" alt="Email Detail" /></td>
|
|
22
|
+
</tr>
|
|
23
|
+
<tr>
|
|
24
|
+
<td colspan="2" align="center"><em>Email detail with full SNS event timeline</em></td>
|
|
25
|
+
</tr>
|
|
26
|
+
<tr>
|
|
27
|
+
<td colspan="2"><img src="docs/screenshots/webhook-forward-ruleset.png" alt="Webhook Forward Rules" /></td>
|
|
28
|
+
</tr>
|
|
29
|
+
<tr>
|
|
30
|
+
<td colspan="2" align="center"><em>Per-project webhook forwarding with configurable rules</em></td>
|
|
31
|
+
</tr>
|
|
32
|
+
</table>
|
|
33
|
+
|
|
34
|
+
## Features
|
|
35
|
+
|
|
36
|
+
- **Real-time webhook processing** -- receives SNS notifications for delivery, bounce, complaint, open, click, reject, and rendering failure events
|
|
37
|
+
- **Per-project dashboards** -- stat cards, email volume charts (Chart.js), and paginated activity logs
|
|
38
|
+
- **Webhook forwarding with rules** -- forward specific events to external URLs (e.g. Zapier) with a configurable rules engine; filter by event type, sender, recipient, subject, and more
|
|
39
|
+
- **Pluggable authentication** -- ships with Devise, Cloudflare Zero Trust, and no-auth adapters; bring your own with any object that responds to `#authenticate(request)`
|
|
40
|
+
- **CSV/JSON export** -- export filtered email activity from any project
|
|
41
|
+
- **Test email sending** -- send test emails directly from the dashboard via the SES API
|
|
42
|
+
- **Status state machine** -- unidirectional email status transitions (sent -> delivered/bounced/etc.)
|
|
43
|
+
- **SNS signature verification** -- validates RSA signatures (SHA1 and SHA256) on incoming SNS messages
|
|
44
|
+
- **Database agnostic** -- works with SQLite, PostgreSQL, and MySQL
|
|
45
|
+
- **Lightweight pagination** -- no external pagination gem required
|
|
46
|
+
|
|
47
|
+
## Architecture
|
|
48
|
+
|
|
5
49
|
```mermaid
|
|
6
50
|
graph TB
|
|
7
51
|
subgraph Host["Host Rails App"]
|
|
@@ -29,18 +73,21 @@ graph TB
|
|
|
29
73
|
subgraph Core["Core Library (lib/)"]
|
|
30
74
|
Client["Client<br/>AWS SES SDK wrapper"]
|
|
31
75
|
WP["WebhookProcessor<br/>SNS message parser"]
|
|
76
|
+
SV["SnsSignatureVerifier"]
|
|
32
77
|
SA["StatsAggregator<br/>Dashboard statistics"]
|
|
33
78
|
Pag["Paginatable"]
|
|
79
|
+
FR["ForwardRule<br/>field/operator/value matcher"]
|
|
34
80
|
end
|
|
35
81
|
|
|
36
82
|
subgraph Models
|
|
37
|
-
Project["Project<br/>name, token"]
|
|
83
|
+
Project["Project<br/>name, token, webhook_forwards"]
|
|
38
84
|
Email["Email<br/>status, opens, clicks"]
|
|
39
85
|
Event["EmailEvent<br/>event_type, event_data"]
|
|
40
86
|
end
|
|
41
87
|
|
|
42
88
|
subgraph Services
|
|
43
89
|
WEP["WebhookEventPersistor"]
|
|
90
|
+
WF["WebhookForwarder<br/>rules-based HTTP forwarding"]
|
|
44
91
|
end
|
|
45
92
|
end
|
|
46
93
|
|
|
@@ -54,8 +101,12 @@ graph TB
|
|
|
54
101
|
DC --> SA
|
|
55
102
|
EC --> Pag
|
|
56
103
|
TC --> Client
|
|
57
|
-
WC -->|"POST /webhook/:token"|
|
|
104
|
+
WC -->|"POST /webhook/:token"| SV
|
|
105
|
+
SV --> WP
|
|
58
106
|
WP --> WEP
|
|
107
|
+
WP --> WF
|
|
108
|
+
WF --> FR
|
|
109
|
+
WF -->|"HTTP POST"| ExtURL["External URL<br/>(Zapier, etc.)"]
|
|
59
110
|
WEP --> Models
|
|
60
111
|
Client --> SES
|
|
61
112
|
SNS -->|"HTTP POST"| WC
|
|
@@ -66,33 +117,25 @@ graph TB
|
|
|
66
117
|
style Engine fill:#f0f4ff,stroke:#3366cc
|
|
67
118
|
style AWS fill:#fff3e0,stroke:#ff9800
|
|
68
119
|
style Host fill:#e8f5e9,stroke:#4caf50
|
|
120
|
+
style ExtURL fill:#fce4ec,stroke:#e91e63
|
|
69
121
|
```
|
|
70
122
|
|
|
71
|
-
## Features
|
|
72
|
-
|
|
73
|
-
- **Real-time webhook processing** -- receives SNS notifications for delivery, bounce, complaint, open, click, reject, and rendering failure events
|
|
74
|
-
- **Per-project dashboards** -- stat cards, email volume charts (Chart.js), and paginated activity logs
|
|
75
|
-
- **Pluggable authentication** -- ships with Devise, Cloudflare Zero Trust, and no-auth adapters; bring your own with any object that responds to `#authenticate(request)`
|
|
76
|
-
- **CSV/JSON export** -- export filtered email activity from any project
|
|
77
|
-
- **Test email sending** -- send test emails directly from the dashboard via the SES API
|
|
78
|
-
- **Status state machine** -- unidirectional email status transitions (sent -> delivered/bounced/etc.)
|
|
79
|
-
- **Database agnostic** -- works with SQLite, PostgreSQL, and MySQL
|
|
80
|
-
- **Lightweight pagination** -- no external pagination gem required
|
|
81
|
-
|
|
82
123
|
## Installation
|
|
83
124
|
|
|
84
125
|
Add the gem to your Gemfile:
|
|
85
126
|
|
|
86
127
|
```ruby
|
|
87
|
-
gem "
|
|
128
|
+
gem "ses-dashboard"
|
|
88
129
|
```
|
|
89
130
|
|
|
131
|
+
The gem name uses a hyphen (`ses-dashboard`) — Bundler will auto-require the correct entry point automatically.
|
|
132
|
+
|
|
90
133
|
Then run:
|
|
91
134
|
|
|
92
135
|
```bash
|
|
93
136
|
bundle install
|
|
94
|
-
rails
|
|
95
|
-
rails db:migrate
|
|
137
|
+
bin/rails railties:install:migrations
|
|
138
|
+
bin/rails db:migrate
|
|
96
139
|
```
|
|
97
140
|
|
|
98
141
|
## Mounting
|
|
@@ -128,8 +171,8 @@ SesDashboard.configure do |c|
|
|
|
128
171
|
c.cloudflare_aud = "your-application-aud"
|
|
129
172
|
|
|
130
173
|
# Dashboard behaviour
|
|
131
|
-
c.per_page
|
|
132
|
-
c.time_zone
|
|
174
|
+
c.per_page = 25 # rows per page in the activity log
|
|
175
|
+
c.time_zone = "UTC" # timezone for chart date grouping
|
|
133
176
|
c.test_email_from = "noreply@example.com"
|
|
134
177
|
|
|
135
178
|
# Caching & security
|
|
@@ -144,24 +187,55 @@ Every controller action (except the webhook endpoint) runs through the configure
|
|
|
144
187
|
|
|
145
188
|
| Adapter | Value | Notes |
|
|
146
189
|
|---|---|---|
|
|
147
|
-
| None | `:none` | Open access
|
|
190
|
+
| None | `:none` | Open access — suitable for development |
|
|
148
191
|
| Devise | `:devise` | Calls `authenticate_user!` via Warden |
|
|
149
192
|
| Cloudflare Zero Trust | `:cloudflare` | Validates `CF_Authorization` JWT against JWKS |
|
|
150
193
|
| Custom | any object | Must respond to `#authenticate(request)` returning truthy/falsy |
|
|
151
194
|
|
|
195
|
+
### Custom adapter
|
|
196
|
+
|
|
197
|
+
Use a custom adapter when your app has its own authentication system (e.g. custom session-based auth, API keys, JWT):
|
|
198
|
+
|
|
199
|
+
```ruby
|
|
200
|
+
# config/initializers/ses_dashboard.rb
|
|
201
|
+
|
|
202
|
+
my_auth = Class.new(SesDashboard::Auth::Base) do
|
|
203
|
+
def authenticate(request)
|
|
204
|
+
session = request.session
|
|
205
|
+
# your auth logic here — return truthy to allow, falsy to deny
|
|
206
|
+
session[:user_id].present?
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
SesDashboard.configure do |c|
|
|
211
|
+
c.authentication_adapter = my_auth.new
|
|
212
|
+
end
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
For apps with session timeout and whitelist checks (e.g. custom Rails session auth):
|
|
216
|
+
|
|
152
217
|
```ruby
|
|
153
|
-
|
|
154
|
-
class ApiKeyAuth
|
|
218
|
+
my_auth = Class.new(SesDashboard::Auth::Base) do
|
|
155
219
|
def authenticate(request)
|
|
156
|
-
|
|
220
|
+
session = request.session
|
|
221
|
+
user_id = session[:user_id]
|
|
222
|
+
logged_in_at = session[:logged_in_at]
|
|
223
|
+
|
|
224
|
+
return false unless user_id && logged_in_at
|
|
225
|
+
return false unless logged_in_at > 12.hours.ago
|
|
226
|
+
|
|
227
|
+
user = User.find_by(id: user_id)
|
|
228
|
+
user&.active? || false
|
|
157
229
|
end
|
|
158
230
|
end
|
|
159
231
|
|
|
160
232
|
SesDashboard.configure do |c|
|
|
161
|
-
c.authentication_adapter =
|
|
233
|
+
c.authentication_adapter = my_auth.new
|
|
162
234
|
end
|
|
163
235
|
```
|
|
164
236
|
|
|
237
|
+
The adapter is defined inline using `Class.new` so it is available at initializer load time without depending on Zeitwerk autoloading.
|
|
238
|
+
|
|
165
239
|
## SNS Webhook Setup
|
|
166
240
|
|
|
167
241
|
Each project gets a unique webhook URL displayed on its dashboard page:
|
|
@@ -178,17 +252,124 @@ To connect it to SES:
|
|
|
178
252
|
|
|
179
253
|
The webhook endpoint authenticates via the project token in the URL and does not require a session.
|
|
180
254
|
|
|
181
|
-
|
|
255
|
+
### SNS Signature Verification
|
|
182
256
|
|
|
183
|
-
|
|
257
|
+
When `verify_sns_signature = true`, the engine validates the RSA signature on every incoming SNS message before processing it. Both `SignatureVersion` `"1"` (SHA1) and `"2"` (SHA256) are supported.
|
|
184
258
|
|
|
185
|
-
|
|
259
|
+
For **raw message delivery** (SNS subscription setting), signature verification is automatically skipped as SNS does not include signature fields in raw payloads — the project token in the URL provides authentication instead.
|
|
260
|
+
|
|
261
|
+
Enable in production:
|
|
262
|
+
|
|
263
|
+
```ruby
|
|
264
|
+
c.verify_sns_signature = Rails.env.production?
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
## Webhook Forwarding
|
|
268
|
+
|
|
269
|
+
Forward SES events to external URLs (e.g. Zapier, Google Sheets, Slack) based on configurable rules. This is useful for routing bounce/complaint notifications into team workflows without consuming unnecessary webhook quota on events you don't need.
|
|
270
|
+
|
|
271
|
+

|
|
272
|
+
|
|
273
|
+
### Per-project configuration (UI)
|
|
274
|
+
|
|
275
|
+
Edit any project in the dashboard and use the **Webhook Forwards** builder to add forward targets with rules. Each target has a URL and optional rules -- all rules must match (AND logic) for the event to be forwarded. No rules means forward everything.
|
|
276
|
+
|
|
277
|
+
### Per-project configuration (database)
|
|
278
|
+
|
|
279
|
+
The `webhook_forwards` column on `ses_dashboard_projects` stores a JSON array:
|
|
280
|
+
|
|
281
|
+
```json
|
|
282
|
+
[
|
|
283
|
+
{
|
|
284
|
+
"url": "https://hooks.zapier.com/hooks/catch/123/abc/",
|
|
285
|
+
"rules": [
|
|
286
|
+
{ "field": "event_type", "operator": "in", "value": ["bounce", "complaint"] }
|
|
287
|
+
]
|
|
288
|
+
}
|
|
289
|
+
]
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### Global fallback (initializer)
|
|
293
|
+
|
|
294
|
+
Used for any project that has no project-level forwards configured:
|
|
295
|
+
|
|
296
|
+
```ruby
|
|
297
|
+
SesDashboard.configure do |c|
|
|
298
|
+
c.webhook_forwards = [
|
|
299
|
+
{ url: "https://hooks.zapier.com/hooks/catch/123/abc/",
|
|
300
|
+
rules: [{ field: "event_type", operator: "in", value: ["bounce", "complaint"] }] }
|
|
301
|
+
]
|
|
302
|
+
end
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
### Rules reference
|
|
306
|
+
|
|
307
|
+
Each rule has a `field`, `operator`, and `value`. All rules on a target must match for the event to be forwarded.
|
|
308
|
+
|
|
309
|
+
| Field | Type | Description |
|
|
310
|
+
|---|---|---|
|
|
311
|
+
| `event_type` | string | `send`, `delivery`, `bounce`, `complaint`, `open`, `click`, `reject`, `rendering_failure` |
|
|
312
|
+
| `source` | string | From: email address |
|
|
313
|
+
| `destination` | array | To: email addresses -- rule passes if **any** recipient matches |
|
|
314
|
+
| `subject` | string | Email subject line |
|
|
315
|
+
|
|
316
|
+
| Operator | Description |
|
|
186
317
|
|---|---|
|
|
187
|
-
| `
|
|
188
|
-
| `
|
|
189
|
-
| `
|
|
318
|
+
| `in` | Value is included in the given array |
|
|
319
|
+
| `not_in` | Value is NOT included in the given array |
|
|
320
|
+
| `eq` | Exact string equality (any element for array fields) |
|
|
321
|
+
| `not_eq` | String inequality |
|
|
322
|
+
| `starts_with` | Prefix match (any element for array fields) |
|
|
323
|
+
| `ends_with` | Suffix match (any element for array fields) |
|
|
324
|
+
| `contains` | Substring match (any element for array fields) |
|
|
325
|
+
|
|
326
|
+
### Forwarded payload
|
|
327
|
+
|
|
328
|
+
Each matching URL receives a `POST` with a JSON body:
|
|
329
|
+
|
|
330
|
+
```json
|
|
331
|
+
{
|
|
332
|
+
"event_type": "bounce",
|
|
333
|
+
"message_id": "0102018e-abcd-1234-...",
|
|
334
|
+
"source": "noreply@myapp.com",
|
|
335
|
+
"destination": ["user@example.com"],
|
|
336
|
+
"subject": "Your invoice #1234",
|
|
337
|
+
"occurred_at": "2024-01-15T10:00:00Z",
|
|
338
|
+
"raw": { "...full SES event hash..." }
|
|
339
|
+
}
|
|
340
|
+
```
|
|
190
341
|
|
|
191
|
-
|
|
342
|
+
### Examples
|
|
343
|
+
|
|
344
|
+
Forward only bounces and complaints to Zapier:
|
|
345
|
+
|
|
346
|
+
```json
|
|
347
|
+
[
|
|
348
|
+
{
|
|
349
|
+
"url": "https://hooks.zapier.com/hooks/catch/123/abc/",
|
|
350
|
+
"rules": [
|
|
351
|
+
{ "field": "event_type", "operator": "in", "value": ["bounce", "complaint"] }
|
|
352
|
+
]
|
|
353
|
+
}
|
|
354
|
+
]
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
Forward bounces from a specific sender domain to one URL, all events to another:
|
|
358
|
+
|
|
359
|
+
```json
|
|
360
|
+
[
|
|
361
|
+
{
|
|
362
|
+
"url": "https://hooks.zapier.com/bounces",
|
|
363
|
+
"rules": [
|
|
364
|
+
{ "field": "event_type", "operator": "eq", "value": "bounce" },
|
|
365
|
+
{ "field": "source", "operator": "ends_with", "value": "@myapp.com" }
|
|
366
|
+
]
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
"url": "https://example.com/all-events"
|
|
370
|
+
}
|
|
371
|
+
]
|
|
372
|
+
```
|
|
192
373
|
|
|
193
374
|
## Development
|
|
194
375
|
|
|
@@ -226,13 +407,27 @@ bundle exec rspec spec/models/ses_dashboard/email_spec.rb:15
|
|
|
226
407
|
| Service | Purpose | Ports |
|
|
227
408
|
|---|---|---|
|
|
228
409
|
| `localstack` | Local AWS (SES + SNS) | 4566 |
|
|
229
|
-
| `chrome` | Selenium standalone Chromium | 4444 (WebDriver), 7900 (noVNC
|
|
410
|
+
| `chrome` | Selenium standalone Chromium | 4444 (WebDriver), 7900 (noVNC — watch tests live) |
|
|
230
411
|
| `web` | Runs the test suite | 4001 (Puma) |
|
|
231
412
|
|
|
232
413
|
### Watching System Tests
|
|
233
414
|
|
|
234
415
|
Open http://localhost:7900 in your browser (no password) to watch Chrome execute system specs in real time via noVNC.
|
|
235
416
|
|
|
417
|
+
## Database Schema
|
|
418
|
+
|
|
419
|
+
The engine creates three tables (prefixed `ses_dashboard_`):
|
|
420
|
+
|
|
421
|
+
| Table | Key Columns |
|
|
422
|
+
|---|---|
|
|
423
|
+
| `ses_dashboard_projects` | `name`, `token` (unique, auto-generated), `description`, `webhook_forwards` (JSON) |
|
|
424
|
+
| `ses_dashboard_emails` | `project_id`, `message_id` (unique), `source`, `destination` (JSON), `subject`, `status`, `opens`, `clicks`, `sent_at` |
|
|
425
|
+
| `ses_dashboard_email_events` | `email_id`, `event_type`, `event_data` (JSON), `occurred_at` |
|
|
426
|
+
|
|
427
|
+
Email statuses: `sent`, `delivered`, `bounced`, `complained`, `rejected`, `failed`.
|
|
428
|
+
|
|
429
|
+
Migrations are compatible with Rails 7.x and 8.x — the migration version is resolved automatically from the host app's Rails version at install time.
|
|
430
|
+
|
|
236
431
|
## License
|
|
237
432
|
|
|
238
433
|
MIT
|
|
@@ -115,6 +115,268 @@
|
|
|
115
115
|
});
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
// ── Webhook Forwards builder ──────────────────────────────────────────
|
|
119
|
+
var WF_FIELDS = [
|
|
120
|
+
{ value: "event_type", label: "Event Type" },
|
|
121
|
+
{ value: "source", label: "From (source)" },
|
|
122
|
+
{ value: "destination", label: "To (destination)" },
|
|
123
|
+
{ value: "subject", label: "Subject" }
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
var WF_OPERATORS = [
|
|
127
|
+
{ value: "in", label: "is in" },
|
|
128
|
+
{ value: "not_in", label: "is not in" },
|
|
129
|
+
{ value: "eq", label: "equals" },
|
|
130
|
+
{ value: "not_eq", label: "does not equal" },
|
|
131
|
+
{ value: "starts_with", label: "starts with" },
|
|
132
|
+
{ value: "ends_with", label: "ends with" },
|
|
133
|
+
{ value: "contains", label: "contains" }
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
var WF_EVENT_TYPES = [
|
|
137
|
+
"send", "delivery", "bounce", "complaint", "open", "click", "reject", "rendering_failure"
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
function wfBuildSelect(options, selected, className) {
|
|
141
|
+
var sel = document.createElement("select");
|
|
142
|
+
sel.className = "form-control " + className;
|
|
143
|
+
options.forEach(function (opt) {
|
|
144
|
+
var o = document.createElement("option");
|
|
145
|
+
o.value = opt.value;
|
|
146
|
+
o.textContent = opt.label;
|
|
147
|
+
if (opt.value === selected) o.selected = true;
|
|
148
|
+
sel.appendChild(o);
|
|
149
|
+
});
|
|
150
|
+
return sel;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function wfBuildCheckboxes(selected) {
|
|
154
|
+
var wrap = document.createElement("div");
|
|
155
|
+
wrap.className = "wf-checkboxes";
|
|
156
|
+
var selectedArr = Array.isArray(selected) ? selected : [];
|
|
157
|
+
WF_EVENT_TYPES.forEach(function (et) {
|
|
158
|
+
var label = document.createElement("label");
|
|
159
|
+
label.className = "wf-checkbox-label";
|
|
160
|
+
var cb = document.createElement("input");
|
|
161
|
+
cb.type = "checkbox";
|
|
162
|
+
cb.value = et;
|
|
163
|
+
cb.className = "wf-event-cb";
|
|
164
|
+
if (selectedArr.indexOf(et) !== -1) cb.checked = true;
|
|
165
|
+
label.appendChild(cb);
|
|
166
|
+
label.appendChild(document.createTextNode(" " + et));
|
|
167
|
+
wrap.appendChild(label);
|
|
168
|
+
});
|
|
169
|
+
return wrap;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function wfBuildTextInput(value, placeholder) {
|
|
173
|
+
var input = document.createElement("input");
|
|
174
|
+
input.type = "text";
|
|
175
|
+
input.className = "form-control wf-value";
|
|
176
|
+
input.placeholder = placeholder || "";
|
|
177
|
+
input.value = value || "";
|
|
178
|
+
return input;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function wfIsArrayOperator(op) {
|
|
182
|
+
return op === "in" || op === "not_in";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function wfBuildValueInput(field, operator, value) {
|
|
186
|
+
if (field === "event_type" && wfIsArrayOperator(operator)) {
|
|
187
|
+
return wfBuildCheckboxes(value);
|
|
188
|
+
}
|
|
189
|
+
if (wfIsArrayOperator(operator)) {
|
|
190
|
+
var display = Array.isArray(value) ? value.join(", ") : (value || "");
|
|
191
|
+
return wfBuildTextInput(display, "value1, value2, value3");
|
|
192
|
+
}
|
|
193
|
+
return wfBuildTextInput(value || "", "Value");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function wfReadValue(valueContainer) {
|
|
197
|
+
// Checkboxes
|
|
198
|
+
var cbs = valueContainer.querySelectorAll(".wf-event-cb");
|
|
199
|
+
if (cbs.length > 0) {
|
|
200
|
+
var vals = [];
|
|
201
|
+
cbs.forEach(function (cb) { if (cb.checked) vals.push(cb.value); });
|
|
202
|
+
return vals;
|
|
203
|
+
}
|
|
204
|
+
// Text input
|
|
205
|
+
var input = valueContainer.querySelector(".wf-value");
|
|
206
|
+
if (input) {
|
|
207
|
+
var sel = valueContainer.closest(".wf-rule").querySelector(".wf-operator");
|
|
208
|
+
if (sel && wfIsArrayOperator(sel.value)) {
|
|
209
|
+
return input.value.split(",").map(function (s) { return s.trim(); }).filter(Boolean);
|
|
210
|
+
}
|
|
211
|
+
return input.value;
|
|
212
|
+
}
|
|
213
|
+
return "";
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function wfSwapValueInput(ruleEl) {
|
|
217
|
+
var fieldSel = ruleEl.querySelector(".wf-field");
|
|
218
|
+
var opSel = ruleEl.querySelector(".wf-operator");
|
|
219
|
+
var valueWrap = ruleEl.querySelector(".wf-value-wrap");
|
|
220
|
+
var currentValue = wfReadValue(valueWrap);
|
|
221
|
+
valueWrap.innerHTML = "";
|
|
222
|
+
valueWrap.appendChild(wfBuildValueInput(fieldSel.value, opSel.value, currentValue));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function wfBuildRule(rule) {
|
|
226
|
+
rule = rule || {};
|
|
227
|
+
var field = rule.field || "event_type";
|
|
228
|
+
var operator = rule.operator || "in";
|
|
229
|
+
var value = rule.value || (wfIsArrayOperator(operator) ? [] : "");
|
|
230
|
+
|
|
231
|
+
var row = document.createElement("div");
|
|
232
|
+
row.className = "wf-rule";
|
|
233
|
+
|
|
234
|
+
var fieldSel = wfBuildSelect(WF_FIELDS, field, "wf-field");
|
|
235
|
+
var opSel = wfBuildSelect(WF_OPERATORS, operator, "wf-operator");
|
|
236
|
+
|
|
237
|
+
var valueWrap = document.createElement("div");
|
|
238
|
+
valueWrap.className = "wf-value-wrap";
|
|
239
|
+
valueWrap.appendChild(wfBuildValueInput(field, operator, value));
|
|
240
|
+
|
|
241
|
+
var removeBtn = document.createElement("button");
|
|
242
|
+
removeBtn.type = "button";
|
|
243
|
+
removeBtn.className = "btn btn-danger btn-sm";
|
|
244
|
+
removeBtn.textContent = "\u00d7";
|
|
245
|
+
removeBtn.addEventListener("click", function () { row.remove(); });
|
|
246
|
+
|
|
247
|
+
// Swap value input when field or operator changes
|
|
248
|
+
fieldSel.addEventListener("change", function () { wfSwapValueInput(row); });
|
|
249
|
+
opSel.addEventListener("change", function () { wfSwapValueInput(row); });
|
|
250
|
+
|
|
251
|
+
row.appendChild(fieldSel);
|
|
252
|
+
row.appendChild(opSel);
|
|
253
|
+
row.appendChild(valueWrap);
|
|
254
|
+
row.appendChild(removeBtn);
|
|
255
|
+
return row;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function wfBuildTarget(target) {
|
|
259
|
+
target = target || {};
|
|
260
|
+
var card = document.createElement("div");
|
|
261
|
+
card.className = "wf-target card";
|
|
262
|
+
|
|
263
|
+
// Header
|
|
264
|
+
var header = document.createElement("div");
|
|
265
|
+
header.className = "wf-target-header";
|
|
266
|
+
var title = document.createElement("span");
|
|
267
|
+
title.className = "wf-target-title";
|
|
268
|
+
title.textContent = "Forward Target";
|
|
269
|
+
var removeBtn = document.createElement("button");
|
|
270
|
+
removeBtn.type = "button";
|
|
271
|
+
removeBtn.className = "btn btn-danger btn-sm";
|
|
272
|
+
removeBtn.textContent = "Remove";
|
|
273
|
+
removeBtn.addEventListener("click", function () { card.remove(); wfUpdateNumbers(); });
|
|
274
|
+
header.appendChild(title);
|
|
275
|
+
header.appendChild(removeBtn);
|
|
276
|
+
card.appendChild(header);
|
|
277
|
+
|
|
278
|
+
// URL
|
|
279
|
+
var urlGroup = document.createElement("div");
|
|
280
|
+
urlGroup.className = "form-group";
|
|
281
|
+
var urlLabel = document.createElement("label");
|
|
282
|
+
urlLabel.className = "form-label";
|
|
283
|
+
urlLabel.textContent = "URL";
|
|
284
|
+
var urlInput = document.createElement("input");
|
|
285
|
+
urlInput.type = "text";
|
|
286
|
+
urlInput.className = "form-control wf-url";
|
|
287
|
+
urlInput.placeholder = "https://hooks.zapier.com/hooks/catch/...";
|
|
288
|
+
urlInput.value = target.url || "";
|
|
289
|
+
urlGroup.appendChild(urlLabel);
|
|
290
|
+
urlGroup.appendChild(urlInput);
|
|
291
|
+
card.appendChild(urlGroup);
|
|
292
|
+
|
|
293
|
+
// Rules
|
|
294
|
+
var rulesLabel = document.createElement("label");
|
|
295
|
+
rulesLabel.className = "form-label";
|
|
296
|
+
rulesLabel.textContent = "Rules";
|
|
297
|
+
rulesLabel.style.marginBottom = ".25rem";
|
|
298
|
+
card.appendChild(rulesLabel);
|
|
299
|
+
|
|
300
|
+
var rulesWrap = document.createElement("div");
|
|
301
|
+
rulesWrap.className = "wf-rules";
|
|
302
|
+
var rules = target.rules || [];
|
|
303
|
+
rules.forEach(function (r) { rulesWrap.appendChild(wfBuildRule(r)); });
|
|
304
|
+
card.appendChild(rulesWrap);
|
|
305
|
+
|
|
306
|
+
var addRuleBtn = document.createElement("button");
|
|
307
|
+
addRuleBtn.type = "button";
|
|
308
|
+
addRuleBtn.className = "btn btn-outline btn-sm";
|
|
309
|
+
addRuleBtn.textContent = "+ Add Rule";
|
|
310
|
+
addRuleBtn.addEventListener("click", function () {
|
|
311
|
+
rulesWrap.appendChild(wfBuildRule());
|
|
312
|
+
});
|
|
313
|
+
card.appendChild(addRuleBtn);
|
|
314
|
+
|
|
315
|
+
return card;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function wfUpdateNumbers() {
|
|
319
|
+
var targets = document.querySelectorAll(".wf-target");
|
|
320
|
+
targets.forEach(function (t, i) {
|
|
321
|
+
t.querySelector(".wf-target-title").textContent = "Forward Target #" + (i + 1);
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function wfSerialize() {
|
|
326
|
+
var targets = document.querySelectorAll(".wf-target");
|
|
327
|
+
var result = [];
|
|
328
|
+
targets.forEach(function (t) {
|
|
329
|
+
var url = t.querySelector(".wf-url").value.trim();
|
|
330
|
+
if (!url) return;
|
|
331
|
+
var rules = [];
|
|
332
|
+
t.querySelectorAll(".wf-rule").forEach(function (r) {
|
|
333
|
+
var field = r.querySelector(".wf-field").value;
|
|
334
|
+
var op = r.querySelector(".wf-operator").value;
|
|
335
|
+
var val = wfReadValue(r.querySelector(".wf-value-wrap"));
|
|
336
|
+
if (wfIsArrayOperator(op) && Array.isArray(val) && val.length === 0) return;
|
|
337
|
+
if (!wfIsArrayOperator(op) && val === "") return;
|
|
338
|
+
rules.push({ field: field, operator: op, value: val });
|
|
339
|
+
});
|
|
340
|
+
var entry = { url: url };
|
|
341
|
+
if (rules.length > 0) entry.rules = rules;
|
|
342
|
+
result.push(entry);
|
|
343
|
+
});
|
|
344
|
+
return result;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function initWebhookForwardsBuilder() {
|
|
348
|
+
var container = document.getElementById("wf-targets");
|
|
349
|
+
var addBtn = document.getElementById("wf-add-target");
|
|
350
|
+
var hidden = document.getElementById("webhook-forwards-json");
|
|
351
|
+
if (!container || !addBtn || !hidden) return;
|
|
352
|
+
|
|
353
|
+
// Load initial data
|
|
354
|
+
var dataEl = document.getElementById("wf-initial-data");
|
|
355
|
+
var initial = [];
|
|
356
|
+
if (dataEl && dataEl.textContent.trim()) {
|
|
357
|
+
try { initial = JSON.parse(dataEl.textContent); } catch (e) { /* ignore */ }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
initial.forEach(function (t) {
|
|
361
|
+
container.appendChild(wfBuildTarget(t));
|
|
362
|
+
});
|
|
363
|
+
wfUpdateNumbers();
|
|
364
|
+
|
|
365
|
+
addBtn.addEventListener("click", function () {
|
|
366
|
+
container.appendChild(wfBuildTarget());
|
|
367
|
+
wfUpdateNumbers();
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// Serialize to hidden field on submit
|
|
371
|
+
var form = hidden.closest("form");
|
|
372
|
+
if (form) {
|
|
373
|
+
form.addEventListener("submit", function () {
|
|
374
|
+
var data = wfSerialize();
|
|
375
|
+
hidden.value = data.length > 0 ? JSON.stringify(data) : "";
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
118
380
|
// ── Boot ─────────────────────────────────────────────────────────────
|
|
119
381
|
document.addEventListener("DOMContentLoaded", function () {
|
|
120
382
|
initChart();
|
|
@@ -122,5 +384,6 @@
|
|
|
122
384
|
initEventToggles();
|
|
123
385
|
initFilterReset();
|
|
124
386
|
initConfirmLinks();
|
|
387
|
+
initWebhookForwardsBuilder();
|
|
125
388
|
});
|
|
126
389
|
})();
|