44 "context"
55 "fmt"
66 "math"
7+ "net/url"
78 "sort"
89 "strings"
910 "time"
@@ -220,15 +221,31 @@ func buildNotifyPlan(caseID string, profile caseRoutingProfile, targets []models
220221 }
221222 }
222223
224+ sortedTargets := sortTargetsForNotifyPlan (profile , targets )
223225 items := make ([]models.CaseNotifyPlanItem , 0 , len (targets ))
224226 perWaveRank := make (map [int ]int )
225227 selectedCountByScope := make (map [string ]int )
226- for _ , target := range targets {
228+ selectedCountByOrgWave := make (map [string ]int )
229+ selectedCountByDedupKey := make (map [string ]int )
230+ for _ , target := range sortedTargets {
227231 wave := notifyTierForTarget (profile , target )
228232 perWaveRank [wave ]++
229- selected := shouldSelectTargetForNotifyPlan (profile , target , selectedCountByScope )
233+ selected := shouldSelectTargetForNotifyPlan (
234+ profile ,
235+ target ,
236+ wave ,
237+ selectedCountByScope ,
238+ selectedCountByOrgWave ,
239+ selectedCountByDedupKey ,
240+ )
230241 if selected {
231242 selectedCountByScope [target .DecisionScope ]++
243+ if key := orgWaveSelectionKey (wave , target ); key != "" {
244+ selectedCountByOrgWave [key ]++
245+ }
246+ if key := targetSelectionDedupKey (wave , target ); key != "" {
247+ selectedCountByDedupKey [key ]++
248+ }
232249 }
233250 observationID := observationIDs [observationKeyForTarget (target )]
234251 var observationPtr * int64
@@ -263,6 +280,117 @@ func buildNotifyPlan(caseID string, profile caseRoutingProfile, targets []models
263280 }
264281}
265282
283+ func sortTargetsForNotifyPlan (profile caseRoutingProfile , targets []models.CaseEscalationTarget ) []models.CaseEscalationTarget {
284+ sorted := append ([]models.CaseEscalationTarget (nil ), targets ... )
285+ sort .SliceStable (sorted , func (i , j int ) bool {
286+ left := sorted [i ]
287+ right := sorted [j ]
288+ leftWave := notifyTierForTarget (profile , left )
289+ rightWave := notifyTierForTarget (profile , right )
290+ if leftWave != rightWave {
291+ return leftWave < rightWave
292+ }
293+ if left .DecisionScope != right .DecisionScope {
294+ return decisionScopePriority (left .DecisionScope ) < decisionScopePriority (right .DecisionScope )
295+ }
296+ leftOrg := organizationBucketKey (left )
297+ rightOrg := organizationBucketKey (right )
298+ if leftOrg != "" && leftOrg == rightOrg {
299+ leftEndpointRank := primaryEndpointRank (left )
300+ rightEndpointRank := primaryEndpointRank (right )
301+ if leftEndpointRank != rightEndpointRank {
302+ return leftEndpointRank < rightEndpointRank
303+ }
304+ leftRoleRank := rolePriorityForSelection (left .RoleType )
305+ rightRoleRank := rolePriorityForSelection (right .RoleType )
306+ if leftRoleRank != rightRoleRank {
307+ return leftRoleRank > rightRoleRank
308+ }
309+ }
310+ if ! almostEqualFloat (left .ActionabilityScore , right .ActionabilityScore ) {
311+ return left .ActionabilityScore > right .ActionabilityScore
312+ }
313+ leftEndpointRank := primaryEndpointRank (left )
314+ rightEndpointRank := primaryEndpointRank (right )
315+ if leftEndpointRank != rightEndpointRank {
316+ return leftEndpointRank < rightEndpointRank
317+ }
318+ if ! almostEqualFloat (left .ConfidenceScore , right .ConfidenceScore ) {
319+ return left .ConfidenceScore > right .ConfidenceScore
320+ }
321+ return strings .ToLower (strings .TrimSpace (firstNonEmpty (left .DisplayName , left .Organization , left .Email , left .Phone ))) <
322+ strings .ToLower (strings .TrimSpace (firstNonEmpty (right .DisplayName , right .Organization , right .Email , right .Phone )))
323+ })
324+ return sorted
325+ }
326+
327+ func decisionScopePriority (scope string ) int {
328+ switch scope {
329+ case "site_ops" :
330+ return 0
331+ case "asset_owner" :
332+ return 1
333+ case "regulator" :
334+ return 2
335+ case "project_party" :
336+ return 3
337+ default :
338+ return 4
339+ }
340+ }
341+
342+ func rolePriorityForSelection (roleType string ) int {
343+ switch strings .ToLower (strings .TrimSpace (roleType )) {
344+ case "operator" , "operator_admin" , "site_leadership" :
345+ return 9
346+ case "facility_manager" , "transit_authority" , "public_works" , "traffic_authority" , "infrastructure_authority" :
347+ return 8
348+ case "building_authority" , "public_safety" , "fire_authority" , "transit_safety" :
349+ return 7
350+ case "owner" , "property_owner" , "landlord" :
351+ return 6
352+ case "support" , "engineering" , "product_owner" , "trust_safety" , "security" :
353+ return 5
354+ case "architect" , "engineer" , "contractor" :
355+ return 4
356+ default :
357+ return 1
358+ }
359+ }
360+
361+ func primaryEndpointRank (target models.CaseEscalationTarget ) int {
362+ switch caseTargetChannel (target ) {
363+ case "email" :
364+ localPart := strings .ToLower (strings .TrimSpace (target .Email ))
365+ if at := strings .Index (localPart , "@" ); at >= 0 {
366+ localPart = localPart [:at ]
367+ }
368+ switch {
369+ case containsAny (localPart , []string {"noreply" , "no-reply" , "donotreply" , "do-not-reply" }):
370+ return 6
371+ case containsAny (localPart , []string {"press" , "media" , "marketing" , "kommunikation" , "communication" , "webmaster" , "web" }):
372+ return 4
373+ case containsAny (localPart , []string {"info" , "contact" , "support" , "help" , "service" , "team" , "admin" , "office" , "facilit" , "maint" , "security" , "safety" , "inspection" , "permit" , "planning" , "bau" , "school" , "station" , "publicworks" , "transit" , "operations" , "ops" }):
374+ return 0
375+ case strings .Contains (localPart , "." ) || strings .Contains (localPart , "_" ):
376+ return 2
377+ default :
378+ return 1
379+ }
380+ case "phone" :
381+ return 1
382+ case "website" :
383+ if strings .TrimSpace (target .ContactURL ) != "" {
384+ return 3
385+ }
386+ return 4
387+ case "social" :
388+ return 5
389+ default :
390+ return 7
391+ }
392+ }
393+
266394func buildCaseRoutingProfile (detail * models.CaseDetail ) caseRoutingProfile {
267395 textParts := []string {
268396 detail .Case .Title ,
@@ -545,29 +673,42 @@ func buildCaseTargetReason(profile caseRoutingProfile, target models.CaseEscalat
545673
546674func buildCaseNotifyPlanSummary (profile caseRoutingProfile , items []models.CaseNotifyPlanItem ) string {
547675 selected := 0
548- authorities := 0
676+ selectedDirect := 0
677+ backupAuthorities := 0
549678 for _ , item := range items {
550679 if item .Selected {
551680 selected ++
681+ if item .DecisionScope == "site_ops" || item .DecisionScope == "asset_owner" {
682+ selectedDirect ++
683+ }
552684 }
553685 if item .DecisionScope == "regulator" {
554- authorities ++
686+ if ! item .Selected {
687+ backupAuthorities ++
688+ }
555689 }
556690 }
557691 switch profile .HazardMode {
558692 case "emergency" :
559- return fmt .Sprintf ("Immediate-response plan prioritizes %d direct operators/owners first, with %d authority targets ready in the next wave ." , selected , authorities )
693+ return fmt .Sprintf ("Immediate-response plan selects %d primary recipients now, while holding %d authority contacts in reserve for widening if needed ." , selected , backupAuthorities )
560694 case "urgent" :
561- return fmt .Sprintf ("Urgent notify plan focuses on %d direct operators/owners now and keeps %d authority stakeholders queued for escalation." , selected , authorities )
695+ return fmt .Sprintf ("Urgent notify plan focuses on %d direct operators/owners now and keeps %d authority stakeholders queued for escalation." , selectedDirect , backupAuthorities )
562696 default :
563697 if strings .HasPrefix (profile .DefectClass , "digital_" ) {
564- return fmt .Sprintf ("Notify plan recommends %d primary product/operator contacts now, with %d authority or oversight stakeholders retained for escalation if the issue persists." , selected , authorities )
698+ return fmt .Sprintf ("Notify plan recommends %d primary product/operator contacts now, with %d authority or oversight stakeholders retained for escalation if the issue persists." , selected , backupAuthorities )
565699 }
566- return fmt .Sprintf ("Notify plan recommends %d primary contacts now, with %d authority or oversight stakeholders retained for escalation if needed." , selected , authorities )
700+ return fmt .Sprintf ("Notify plan recommends %d primary contacts now, with %d authority or oversight stakeholders retained for escalation if needed." , selected , backupAuthorities )
567701 }
568702}
569703
570- func shouldSelectTargetForNotifyPlan (profile caseRoutingProfile , target models.CaseEscalationTarget , selectedCountByScope map [string ]int ) bool {
704+ func shouldSelectTargetForNotifyPlan (
705+ profile caseRoutingProfile ,
706+ target models.CaseEscalationTarget ,
707+ wave int ,
708+ selectedCountByScope map [string ]int ,
709+ selectedCountByOrgWave map [string ]int ,
710+ selectedCountByDedupKey map [string ]int ,
711+ ) bool {
571712 if target .SendEligibility != "auto" {
572713 return false
573714 }
@@ -587,15 +728,148 @@ func shouldSelectTargetForNotifyPlan(profile caseRoutingProfile, target models.C
587728 default :
588729 limit = 1
589730 }
590- return selectedCountByScope [scope ] < limit
731+ if selectedCountByScope [scope ] >= limit {
732+ return false
733+ }
734+ if key := orgWaveSelectionKey (wave , target ); key != "" {
735+ if selectedCountByOrgWave [key ] >= orgWaveSelectionLimit (profile , target , wave ) {
736+ return false
737+ }
738+ }
739+ if key := targetSelectionDedupKey (wave , target ); key != "" {
740+ if selectedCountByDedupKey [key ] > 0 {
741+ return false
742+ }
743+ }
744+ return true
745+ }
746+
747+ func orgWaveSelectionKey (wave int , target models.CaseEscalationTarget ) string {
748+ orgKey := organizationBucketKey (target )
749+ if orgKey == "" {
750+ return ""
751+ }
752+ return fmt .Sprintf ("%d|%s" , wave , orgKey )
753+ }
754+
755+ func orgWaveSelectionLimit (profile caseRoutingProfile , target models.CaseEscalationTarget , wave int ) int {
756+ switch target .DecisionScope {
757+ case "site_ops" :
758+ if wave == 1 {
759+ return 2
760+ }
761+ return 1
762+ case "regulator" :
763+ if profile .HazardMode == "emergency" {
764+ switch roleFamilyForTarget (target ) {
765+ case "public_safety" , "fire_authority" :
766+ return 2
767+ }
768+ }
769+ return 1
770+ default :
771+ return 1
772+ }
773+ }
774+
775+ func targetSelectionDedupKey (wave int , target models.CaseEscalationTarget ) string {
776+ orgKey := organizationBucketKey (target )
777+ if orgKey == "" {
778+ return ""
779+ }
780+ return fmt .Sprintf ("%d|%s|%s" , wave , orgKey , roleFamilyForTarget (target ))
781+ }
782+
783+ func roleFamilyForTarget (target models.CaseEscalationTarget ) string {
784+ switch strings .ToLower (strings .TrimSpace (target .RoleType )) {
785+ case "operator" , "operator_admin" , "site_leadership" :
786+ return "site_operator"
787+ case "facility_manager" :
788+ return "facility_manager"
789+ case "owner" , "property_owner" , "landlord" :
790+ return "asset_owner"
791+ case "building_authority" :
792+ return "building_authority"
793+ case "public_safety" :
794+ return "public_safety"
795+ case "fire_authority" :
796+ return "fire_authority"
797+ case "transit_authority" :
798+ return "transit_authority"
799+ case "transit_safety" :
800+ return "transit_safety"
801+ case "public_works" :
802+ return "public_works"
803+ case "traffic_authority" :
804+ return "traffic_authority"
805+ case "infrastructure_authority" :
806+ return "infrastructure_authority"
807+ case "support" :
808+ return "support"
809+ case "engineering" :
810+ return "engineering"
811+ case "product_owner" :
812+ return "product_owner"
813+ case "security" :
814+ return "security"
815+ case "trust_safety" :
816+ return "trust_safety"
817+ case "architect" :
818+ return "architect"
819+ case "engineer" :
820+ return "engineer"
821+ case "contractor" :
822+ return "contractor"
823+ default :
824+ return strings .ToLower (strings .TrimSpace (firstNonEmpty (target .RoleType , target .DecisionScope , "other" )))
825+ }
591826}
592827
593828func endpointKeyForTarget (target models.CaseEscalationTarget ) string {
594829 return caseEscalationTargetKey (target )
595830}
596831
597832func organizationKeyForTarget (target models.CaseEscalationTarget ) string {
598- return strings .ToLower (strings .TrimSpace (firstNonEmpty (target .Organization , target .DisplayName )))
833+ return organizationBucketKey (target )
834+ }
835+
836+ func organizationBucketKey (target models.CaseEscalationTarget ) string {
837+ if key := strings .ToLower (strings .TrimSpace (target .OrganizationKey )); key != "" {
838+ return key
839+ }
840+ if key := strings .ToLower (strings .TrimSpace (firstNonEmpty (target .Organization , target .DisplayName ))); key != "" {
841+ return key
842+ }
843+ for _ , raw := range []string {target .ContactURL , target .Website , target .SourceURL } {
844+ if host := normalizedTargetHost (raw ); host != "" {
845+ return host
846+ }
847+ }
848+ if target .Email != "" {
849+ normalized := strings .ToLower (strings .TrimSpace (target .Email ))
850+ if at := strings .LastIndex (normalized , "@" ); at >= 0 {
851+ return normalized [at + 1 :]
852+ }
853+ }
854+ if target .Phone != "" {
855+ return "phone:" + strings .TrimSpace (target .Phone )
856+ }
857+ return ""
858+ }
859+
860+ func normalizedTargetHost (raw string ) string {
861+ if strings .TrimSpace (raw ) == "" {
862+ return ""
863+ }
864+ parsed , err := url .Parse (raw )
865+ if err != nil {
866+ return ""
867+ }
868+ return strings .ToLower (strings .TrimSpace (parsed .Hostname ()))
869+ }
870+
871+ func almostEqualFloat (left , right float64 ) bool {
872+ return math .Abs (left - right ) < 1e-9
599873}
600874
601875func scoreSourceQuality (target models.CaseEscalationTarget ) float64 {
0 commit comments