Skip to content

Commit a96b451

Browse files
authored
Merge pull request #12 from Traction-Rec/release/0.4.2
Release 0.4.2
2 parents bfc6aac + 8f20891 commit a96b451

4 files changed

Lines changed: 153 additions & 7 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "n8n-unihook"
3-
version = "0.4.1"
3+
version = "0.4.2"
44
edition = "2024"
55
description = "A Slack event router for n8n that enables multiple workflows to share a single Slack app webhook"
66
license = "MIT"

scripts/run-integration-tests.sh

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,9 +177,12 @@ setup_n8n() {
177177
# Calculate expiration timestamp (1 year from now in milliseconds)
178178
local expires_at=$(($(date +%s) * 1000 + 365 * 24 * 60 * 60 * 1000))
179179

180+
# Scopes must match n8n's allowed API key scopes for the instance owner role
181+
# (see n8n packages/@n8n/permissions/src/public-api-permissions.ee.ts — there is
182+
# no workflow:execute; use workflow:activate/deactivate and execution:* instead).
180183
local api_key_response=$(curl -s -b "$cookie_jar" -X POST http://localhost:6789/rest/api-keys \
181184
-H "Content-Type: application/json" \
182-
-d "{\"label\":\"integration-test-key\",\"scopes\":[\"workflow:create\",\"workflow:delete\",\"workflow:read\",\"workflow:update\",\"workflow:list\",\"workflow:execute\"],\"expiresAt\":$expires_at}")
185+
-d "{\"label\":\"integration-test-key\",\"scopes\":[\"workflow:create\",\"workflow:delete\",\"workflow:read\",\"workflow:update\",\"workflow:list\",\"workflow:activate\",\"workflow:deactivate\",\"execution:read\",\"execution:list\"],\"expiresAt\":$expires_at}")
183186

184187
# Extract raw API key from response
185188
N8N_API_KEY=$(echo "$api_key_response" | grep -o '"rawApiKey":"[^"]*"' | cut -d'"' -f4)

src/db.rs

Lines changed: 142 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use parking_lot::Mutex;
22
use rusqlite::{Connection, OptionalExtension};
3-
use tracing::{debug, info};
3+
use std::collections::HashSet;
4+
use tracing::{debug, info, warn};
45

56
use crate::github::GitHubTriggerConfig;
67
use crate::jira::JiraTriggerConfig;
@@ -49,6 +50,8 @@ pub struct SlackTriggerRow {
4950
pub watch_whole_workspace: bool,
5051
}
5152

53+
type TriggerDedupSortKey = (String, bool, String, String);
54+
5255
impl Database {
5356
/// Open (or create) the database at `path` and run migrations.
5457
/// Use `":memory:"` for an in-memory database (useful for tests).
@@ -203,10 +206,15 @@ impl Database {
203206

204207
/// Replace all GitHub trigger rows with the supplied set (inside a
205208
/// transaction). This is called by the periodic sync job.
209+
///
210+
/// If n8n returns multiple trigger nodes with the same `webhook_id`, only one
211+
/// row per id is kept (active workflows win, then lexicographic `workflow_id`)
212+
/// so the sync transaction does not abort with a UNIQUE constraint error.
206213
pub fn sync_github_triggers(
207214
&self,
208215
triggers: &[GitHubTriggerConfig],
209216
) -> Result<(), rusqlite::Error> {
217+
let triggers = dedupe_github_triggers(triggers);
210218
let mut conn = self.conn.lock();
211219
let tx = conn.transaction()?;
212220
tx.execute("DELETE FROM github_triggers", [])?;
@@ -216,7 +224,7 @@ impl Database {
216224
(webhook_id, workflow_id, workflow_name, workflow_active, owner, repository, events) \
217225
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
218226
)?;
219-
for t in triggers {
227+
for t in &triggers {
220228
let events_json =
221229
serde_json::to_string(&t.events).unwrap_or_else(|_| "[]".to_string());
222230
stmt.execute(rusqlite::params![
@@ -299,10 +307,14 @@ impl Database {
299307
// ── Jira triggers ───────────────────────────────────────────────────
300308

301309
/// Replace all Jira trigger rows with the supplied set.
310+
///
311+
/// Duplicate `webhook_id` values from n8n are collapsed to one row each so
312+
/// the SQLite UNIQUE constraint cannot roll back the entire sync.
302313
pub fn sync_jira_triggers(
303314
&self,
304315
triggers: &[JiraTriggerConfig],
305316
) -> Result<(), rusqlite::Error> {
317+
let triggers = dedupe_jira_triggers(triggers);
306318
let mut conn = self.conn.lock();
307319
let tx = conn.transaction()?;
308320
tx.execute("DELETE FROM jira_triggers", [])?;
@@ -312,7 +324,7 @@ impl Database {
312324
(webhook_id, workflow_id, workflow_name, workflow_active, events) \
313325
VALUES (?1, ?2, ?3, ?4, ?5)",
314326
)?;
315-
for t in triggers {
327+
for t in &triggers {
316328
let events_json =
317329
serde_json::to_string(&t.events).unwrap_or_else(|_| "[]".to_string());
318330
stmt.execute(rusqlite::params![
@@ -362,10 +374,14 @@ impl Database {
362374
// ── Slack triggers ──────────────────────────────────────────────────
363375

364376
/// Replace all Slack trigger rows with the supplied set.
377+
///
378+
/// Duplicate `webhook_id` values from n8n are collapsed to one row each so
379+
/// the SQLite UNIQUE constraint cannot roll back the entire sync.
365380
pub fn sync_slack_triggers(
366381
&self,
367382
triggers: &[SlackTriggerConfig],
368383
) -> Result<(), rusqlite::Error> {
384+
let triggers = dedupe_slack_triggers(triggers);
369385
let mut conn = self.conn.lock();
370386
let tx = conn.transaction()?;
371387
tx.execute("DELETE FROM slack_triggers", [])?;
@@ -375,7 +391,7 @@ impl Database {
375391
(webhook_id, workflow_id, workflow_name, workflow_active, event_type, channels, watch_whole_workspace) \
376392
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
377393
)?;
378-
for t in triggers {
394+
for t in &triggers {
379395
let channels_json =
380396
serde_json::to_string(&t.channels).unwrap_or_else(|_| "[]".to_string());
381397
stmt.execute(rusqlite::params![
@@ -429,6 +445,96 @@ impl Database {
429445
}
430446
}
431447

448+
fn dedupe_slack_triggers(triggers: &[SlackTriggerConfig]) -> Vec<SlackTriggerConfig> {
449+
dedupe_by_webhook_id(
450+
triggers.to_vec(),
451+
|t| {
452+
(
453+
t.webhook_id.clone(),
454+
t.workflow_active,
455+
t.workflow_id.clone(),
456+
t.workflow_name.clone(),
457+
)
458+
},
459+
"Slack",
460+
)
461+
}
462+
463+
fn dedupe_github_triggers(triggers: &[GitHubTriggerConfig]) -> Vec<GitHubTriggerConfig> {
464+
dedupe_by_webhook_id(
465+
triggers.to_vec(),
466+
|t| {
467+
(
468+
t.webhook_id.clone(),
469+
t.workflow_active,
470+
t.workflow_id.clone(),
471+
t.workflow_name.clone(),
472+
)
473+
},
474+
"GitHub",
475+
)
476+
}
477+
478+
fn dedupe_jira_triggers(triggers: &[JiraTriggerConfig]) -> Vec<JiraTriggerConfig> {
479+
dedupe_by_webhook_id(
480+
triggers.to_vec(),
481+
|t| {
482+
(
483+
t.webhook_id.clone(),
484+
t.workflow_active,
485+
t.workflow_id.clone(),
486+
t.workflow_name.clone(),
487+
)
488+
},
489+
"Jira",
490+
)
491+
}
492+
493+
/// Stable sort then keep first row per `webhook_id` so SQLite `UNIQUE` sync never fails.
494+
fn dedupe_by_webhook_id<T, F>(mut rows: Vec<T>, key_fn: F, provider: &'static str) -> Vec<T>
495+
where
496+
F: Fn(&T) -> TriggerDedupSortKey,
497+
{
498+
let original = rows.len();
499+
// Same webhook_id together; prefer active workflow, then stable workflow id/name.
500+
rows.sort_by(|a, b| {
501+
let (wa, active_a, ida, na) = key_fn(a);
502+
let (wb, active_b, idb, nb) = key_fn(b);
503+
wa.cmp(&wb)
504+
.then_with(|| active_b.cmp(&active_a))
505+
.then_with(|| ida.cmp(&idb))
506+
.then_with(|| na.cmp(&nb))
507+
});
508+
509+
let mut seen = HashSet::new();
510+
let mut out = Vec::new();
511+
for row in rows {
512+
let (wid, _, wf_id, wf_name) = key_fn(&row);
513+
if seen.contains(&wid) {
514+
warn!(
515+
provider = %provider,
516+
webhook_id = %wid,
517+
workflow_id = %wf_id,
518+
workflow_name = %wf_name,
519+
"Skipping trigger: duplicate webhook_id (another workflow kept for this id)"
520+
);
521+
} else {
522+
seen.insert(wid);
523+
out.push(row);
524+
}
525+
}
526+
if out.len() < original {
527+
warn!(
528+
provider = %provider,
529+
loaded = original,
530+
kept = out.len(),
531+
dropped = original - out.len(),
532+
"Duplicate webhook_id across trigger nodes; assign unique webhook IDs in n8n if multiple workflows must receive the same provider stream"
533+
);
534+
}
535+
out
536+
}
537+
432538
#[cfg(test)]
433539
mod tests {
434540
use super::*;
@@ -659,4 +765,36 @@ mod tests {
659765
assert_eq!(rows[0].channels, vec!["C123"]);
660766
assert!(!rows[0].watch_whole_workspace);
661767
}
768+
769+
#[test]
770+
fn test_sync_slack_triggers_dedupes_duplicate_webhook_id() {
771+
let db = open_memory_db();
772+
773+
let triggers = vec![
774+
SlackTriggerConfig {
775+
webhook_id: "same".to_string(),
776+
workflow_id: "wf-inactive".to_string(),
777+
workflow_name: "Inactive dup".to_string(),
778+
workflow_active: false,
779+
event_type: "message".to_string(),
780+
channels: vec![],
781+
watch_whole_workspace: true,
782+
},
783+
SlackTriggerConfig {
784+
webhook_id: "same".to_string(),
785+
workflow_id: "wf-active".to_string(),
786+
workflow_name: "Active dup".to_string(),
787+
workflow_active: true,
788+
event_type: "any_event".to_string(),
789+
channels: vec![],
790+
watch_whole_workspace: true,
791+
},
792+
];
793+
db.sync_slack_triggers(&triggers).unwrap();
794+
795+
let rows = db.query_slack_triggers().unwrap();
796+
assert_eq!(rows.len(), 1);
797+
assert_eq!(rows[0].workflow_name, "Active dup");
798+
assert!(rows[0].workflow_active);
799+
}
662800
}

tests/integration/common/n8n_client.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ impl N8nTestClient {
139139
.as_millis() as i64
140140
+ (365 * 24 * 60 * 60 * 1000);
141141

142+
// Must align with n8n OWNER_API_KEY_SCOPES (no workflow:execute — use
143+
// workflow:activate/deactivate and execution:* for our test client).
142144
let api_key_payload = serde_json::json!({
143145
"label": "integration-test-key",
144146
"scopes": [
@@ -147,7 +149,10 @@ impl N8nTestClient {
147149
"workflow:read",
148150
"workflow:update",
149151
"workflow:list",
150-
"workflow:execute"
152+
"workflow:activate",
153+
"workflow:deactivate",
154+
"execution:read",
155+
"execution:list"
151156
],
152157
"expiresAt": expires_at
153158
});

0 commit comments

Comments
 (0)