11use parking_lot:: Mutex ;
22use rusqlite:: { Connection , OptionalExtension } ;
3- use tracing:: { debug, info} ;
3+ use std:: collections:: HashSet ;
4+ use tracing:: { debug, info, warn} ;
45
56use crate :: github:: GitHubTriggerConfig ;
67use 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+
5255impl 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) ]
433539mod 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}
0 commit comments