@@ -629,6 +629,84 @@ async def deliver_via_rule(
629629 )
630630
631631
632+ async def _deliver_rule_batch (
633+ db : Database ,
634+ rule : Dict [str , Any ],
635+ findings : List [Dict [str , Any ]],
636+ ) -> List [DeliveryResult ]:
637+ """Deliver a batched notification for one rule across multiple findings.
638+
639+ Sends a single webhook per (rule, task) containing an array of findings,
640+ then records individual delivery records in a batch insert.
641+ """
642+ results : List [DeliveryResult ] = []
643+ channel = str (rule .get ("channel_type" , "" )).lower ()
644+ target = str (rule .get ("target_url_or_email" , "" ))
645+
646+ if channel == NotificationChannelType .WEBHOOK .value :
647+ payloads = [build_alert_payload (finding , rule ) for finding in findings ]
648+ batch_payload = {
649+ "event" : "finding.alert.batch" ,
650+ "rule" : {
651+ "id" : rule .get ("id" ),
652+ "name" : rule .get ("name" ),
653+ "severity_threshold" : rule .get ("severity_threshold" ),
654+ "channel_type" : rule .get ("channel_type" ),
655+ },
656+ "findings" : payloads ,
657+ "count" : len (payloads ),
658+ }
659+
660+ async def _send_and_record ():
661+ ok , error = await send_webhook (target , batch_payload )
662+ status = (
663+ NotificationDeliveryStatus .SUCCESS if ok else NotificationDeliveryStatus .FAILED
664+ )
665+ for finding in findings :
666+ await record_delivery (
667+ db , str (rule ["id" ]), str (finding ["id" ]), status , error ,
668+ )
669+ return [
670+ DeliveryResult (
671+ rule_id = str (rule ["id" ]),
672+ finding_id = str (finding ["id" ]),
673+ status = status ,
674+ error_message = error ,
675+ )
676+ for finding in findings
677+ ]
678+
679+ results = await _send_and_record ()
680+ elif channel == NotificationChannelType .EMAIL .value :
681+ for finding in findings :
682+ payload = build_alert_payload (finding , rule )
683+ ok , error = await send_email (target , payload )
684+ status = (
685+ NotificationDeliveryStatus .SUCCESS if ok else NotificationDeliveryStatus .FAILED
686+ )
687+ await record_delivery (db , str (rule ["id" ]), str (finding ["id" ]), status , error )
688+ results .append (
689+ DeliveryResult (
690+ rule_id = str (rule ["id" ]),
691+ finding_id = str (finding ["id" ]),
692+ status = status ,
693+ error_message = error ,
694+ )
695+ )
696+ else :
697+ for finding in findings :
698+ results .append (
699+ DeliveryResult (
700+ rule_id = str (rule ["id" ]),
701+ finding_id = str (finding ["id" ]),
702+ status = NotificationDeliveryStatus .FAILED ,
703+ error_message = f"Unsupported channel type: { channel } " ,
704+ )
705+ )
706+
707+ return results
708+
709+
632710async def process_finding_notifications (
633711 db : Database ,
634712 finding_id : str ,
@@ -651,14 +729,64 @@ async def process_task_notifications(
651729 db : Database ,
652730 task_id : str ,
653731) -> List [DeliveryResult ]:
654- """Evaluate notifications for every finding produced by a task."""
732+ """Evaluate notifications for every finding produced by a task.
733+
734+ Batches all findings and rules upfront (2 queries), then evaluates
735+ (finding, rule) pairs in-memory to eliminate N+1 query cascading.
736+ Delivery history records are batched and webhook payloads are
737+ deduplicated per (rule, task).
738+ """
655739 findings = await db .fetchall (
656- "SELECT id FROM findings WHERE task_id = ? ORDER BY discovered_at ASC" ,
740+ "SELECT * FROM findings WHERE task_id = ? ORDER BY discovered_at ASC" ,
657741 (task_id ,),
658742 )
743+ if not findings :
744+ return []
745+
746+ rules = await db .fetchall (
747+ "SELECT * FROM notification_rules WHERE is_active = 1 ORDER BY created_at ASC"
748+ )
749+ if not rules :
750+ return []
751+
752+ existing_deliveries = await db .fetchall (
753+ """
754+ SELECT nh.rule_id, nh.finding_id
755+ FROM notification_history nh
756+ JOIN findings f ON f.id = nh.finding_id
757+ WHERE f.task_id = ? AND nh.status = ?
758+ """ ,
759+ (task_id , NotificationDeliveryStatus .SUCCESS .value ),
760+ )
761+ already_delivered = {(row ["rule_id" ], row ["finding_id" ]) for row in existing_deliveries }
762+
763+ rule_map : Dict [str , Dict [str , Any ]] = {str (r ["id" ]): r for r in rules }
764+ pending_by_rule : Dict [str , List [Dict [str , Any ]]] = {}
765+
766+ for finding in findings :
767+ finding_id = str (finding ["id" ])
768+ for rule in rules :
769+ rule_id = str (rule ["id" ])
770+
771+ if not bool (rule .get ("is_active" )):
772+ continue
773+
774+ if not severity_meets_threshold (
775+ str (finding .get ("severity" , "info" )),
776+ str (rule .get ("severity_threshold" , "info" )),
777+ ):
778+ continue
779+
780+ if (rule_id , finding_id ) in already_delivered :
781+ continue
782+
783+ pending_by_rule .setdefault (rule_id , []).append (finding )
784+
659785 results : List [DeliveryResult ] = []
660- for row in findings :
661- results .extend (await process_finding_notifications (db , str (row ["id" ])))
786+ for rule_id , pending_findings in pending_by_rule .items ():
787+ rule = rule_map [rule_id ]
788+ results .extend (await _deliver_rule_batch (db , rule , pending_findings ))
789+
662790 return results
663791
664792
0 commit comments