From b7535486f741f436644049b7ac6d069ee56579ff Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Wed, 5 Aug 2026 12:18:42 +0100 Subject: [PATCH 1/2] feat(alerts): let each alert set its own HaloPSA ticket priority The HaloPSA integration has a single Default Priority that applies to every CIPP-generated ticket. This adds a per-alert override so a risky sign-in alert can open as P1 while a licence alert opens as P4. Mirrors the existing PsaTicketStrategy field - a dropdown on the alert wizard, stored on the alert row, read at send time - and covers both scripted and audit-log alerts. Priority only applies when a new ticket is created: when ConsolidateTickets appends a note to an existing ticket its priority is left alone, the same way tickettype_id is. Falls back to HaloPSA.DefaultPriority when the alert sets none, and to Halo's SLA default when neither is set, so existing alerts behave exactly as before. The dropdown loads its options at page level so the list is ready before the field is revealed, and when the configured Ticket Type has no priorities it is shown disabled with Halo's own explanation rather than as an empty box. Also wraps the HaloPSAFields lookups in @(). PowerShell unrolls single-element output, so a ticket type with one outcome - or any of these lookups returning a single explanatory row - serialised as a bare object instead of a list. --- .../CIPPCore/Public/Add-CIPPScheduledTask.ps1 | 1 + .../CIPPCore/Public/Send-CIPPAlert.ps1 | 5 + .../Public/Send-CIPPScheduledTaskAlert.ps1 | 12 +- .../Webhooks/Invoke-CIPPWebhookProcessing.ps1 | 6 + .../Webhooks/Test-CIPPAuditLogRules.ps1 | 31 +++-- .../Invoke-ExecExtensionMapping.ps1 | 9 +- .../Administration/Alerts/Invoke-AddAlert.ps1 | 21 +-- .../Alerts/Invoke-ListAlertsQueue.ps1 | 17 +-- .../Public/Halo/New-HaloPSATicket.ps1 | 33 +++-- .../Public/New-CippExtAlert.ps1 | 7 + .../New-HaloPSATicket.Priority.Tests.ps1 | 126 ++++++++++++++++++ .../Webhooks/Test-CIPPAuditLogRules.Tests.ps1 | 22 +-- .../alert-configuration/alert.jsx | 104 +++++++++++++++ 13 files changed, 342 insertions(+), 52 deletions(-) create mode 100644 backend/Tests/Extensions/New-HaloPSATicket.Priority.Tests.ps1 diff --git a/backend/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 b/backend/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 index 2ab37de99f..c5a412452f 100644 --- a/backend/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 +++ b/backend/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 @@ -243,6 +243,7 @@ function Add-CIPPScheduledTask { AlertComment = [string]$task.AlertComment CustomSubject = [string]$task.CustomSubject PsaTicketStrategy = [string]($task.PsaTicketStrategy.value ?? $task.PsaTicketStrategy) + PsaTicketPriority = [string]($task.PsaTicketPriority.value ?? $task.PsaTicketPriority) } diff --git a/backend/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 b/backend/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 index d9ef0b2908..b7e3d864eb 100644 --- a/backend/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 +++ b/backend/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 @@ -17,6 +17,7 @@ function Send-CIPPAlert { $RowKey = [string][guid]::NewGuid(), $Attachments, $AffectedUser, + $PsaTicketPriority, [switch]$UseStandardizedSchema ) Write-Information 'Shipping Alert' @@ -348,6 +349,10 @@ function Send-CIPPAlert { $UserLabel = if ($AffectedUser.UPN) { $AffectedUser.UPN } elseif ($AffectedUser.AzureOID) { "OID:$($AffectedUser.AzureOID)" } else { 'unknown' } Write-Information "PSA alert AffectedUser: $UserLabel" } + if ($PsaTicketPriority) { + $Alert.PsaTicketPriority = $PsaTicketPriority + Write-Information "PSA alert priority override: $PsaTicketPriority" + } $PsaResult = New-CippExtAlert -Alert $Alert if ($PsaResult) { Write-Information "PSA result: $PsaResult" diff --git a/backend/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 b/backend/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 index 029e99ce54..c5fcfe4933 100644 --- a/backend/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 +++ b/backend/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 @@ -215,6 +215,12 @@ function Send-CIPPScheduledTaskAlert { '*psa*' { $PsaSplitSent = $false $TaskAffectedUser = $null + # Per-task PSA ticket priority (configured on the alert) overrides the global + # HaloPSA.DefaultPriority. Empty on tasks saved before this field existed, in which + # case New-HaloPSATicket falls back to the integration default. Read here rather + # than inside the try so the consolidated fallback path below can use it even when + # the affected-user resolution throws. + $TaskPsaPriority = $TaskInfo.PsaTicketPriority try { $ExtConfigTable = Get-CIPPTable -TableName Extensionsconfig $ExtConfig = (Get-CIPPAzDataTableEntity @ExtConfigTable).config | ConvertFrom-Json -ErrorAction SilentlyContinue @@ -304,6 +310,7 @@ function Send-CIPPScheduledTaskAlert { # task-level affected user if one was resolved. $GroupParams = @{ Type = 'psa'; Title = $title; HTMLContent = $GroupHTML; TenantFilter = $TenantFilter } if ($TaskAffectedUser) { $GroupParams.AffectedUser = $TaskAffectedUser } + if ($TaskPsaPriority) { $GroupParams.PsaTicketPriority = $TaskPsaPriority } Send-CIPPAlert @GroupParams } else { $GroupDisplayName = if ($DisplayField) { $Group.Group[0].$DisplayField } else { $null } @@ -313,7 +320,9 @@ function Send-CIPPScheduledTaskAlert { UPN = $GroupKey DisplayName = $GroupDisplayName } - Send-CIPPAlert -Type 'psa' -Title $UserTitle -HTMLContent $GroupHTML -TenantFilter $TenantFilter -AffectedUser $AffectedUser + $UserParams = @{ Type = 'psa'; Title = $UserTitle; HTMLContent = $GroupHTML; TenantFilter = $TenantFilter; AffectedUser = $AffectedUser } + if ($TaskPsaPriority) { $UserParams.PsaTicketPriority = $TaskPsaPriority } + Send-CIPPAlert @UserParams } } $PsaSplitSent = $true @@ -327,6 +336,7 @@ function Send-CIPPScheduledTaskAlert { if (-not $PsaSplitSent) { $PsaParams = @{ Type = 'psa'; Title = $title; HTMLContent = (ConvertTo-PSAHtml -Html $HTML); TenantFilter = $TenantFilter } if ($TaskAffectedUser) { $PsaParams.AffectedUser = $TaskAffectedUser } + if ($TaskPsaPriority) { $PsaParams.PsaTicketPriority = $TaskPsaPriority } Send-CIPPAlert @PsaParams } } diff --git a/backend/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 b/backend/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 index ddc0e54de1..55df19431d 100644 --- a/backend/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 +++ b/backend/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 @@ -181,6 +181,12 @@ function Invoke-CippWebhookProcessing { if ($AffectedUser) { $CIPPAlert.AffectedUser = $AffectedUser } + # Per-alert priority rides on the record rather than a function parameter, the same + # way CustomSubject does above - this function has a second caller + # (Push-PublicWebhookProcess) that has no alert config to pass. + if ($Data.CIPPPsaTicketPriority) { + $CIPPAlert.PsaTicketPriority = $Data.CIPPPsaTicketPriority + } Send-CIPPAlert @CIPPAlert } 'generateWebhook' { diff --git a/backend/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 b/backend/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 index 135297724e..c4c5a68f5b 100644 --- a/backend/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 +++ b/backend/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 @@ -159,13 +159,14 @@ function Test-CIPPAuditLogRules { $ExcludedTenants = @(Expand-CIPPTenantGroups -TenantFilter $ExcludedTenants) } [pscustomobject]@{ - Tenants = $Tenants - Excluded = $ExcludedTenants - Conditions = $ConfigEntry.Conditions - Actions = $ConfigEntry.Actions - LogType = $ConfigEntry.Type - AlertComment = $ConfigEntry.AlertComment - CustomSubject = $ConfigEntry.CustomSubject + Tenants = $Tenants + Excluded = $ExcludedTenants + Conditions = $ConfigEntry.Conditions + Actions = $ConfigEntry.Actions + LogType = $ConfigEntry.Type + AlertComment = $ConfigEntry.AlertComment + CustomSubject = $ConfigEntry.CustomSubject + PsaTicketPriority = $ConfigEntry.PsaTicketPriority } } } @@ -618,13 +619,14 @@ function Test-CIPPAuditLogRules { } [PSCustomObject]@{ - conditions = $conditions - expectedAction = $actions - CIPPClause = $CIPPClause - AlertComment = $Config.AlertComment - CustomSubject = $Config.CustomSubject - HasGeoCondition = $HasGeoCondition - ExcludedUserKeys = $LocationExcludedUserKeys + conditions = $conditions + expectedAction = $actions + CIPPClause = $CIPPClause + AlertComment = $Config.AlertComment + CustomSubject = $Config.CustomSubject + PsaTicketPriority = $Config.PsaTicketPriority + HasGeoCondition = $HasGeoCondition + ExcludedUserKeys = $LocationExcludedUserKeys } } } catch { @@ -685,6 +687,7 @@ function Test-CIPPAuditLogRules { $item.CIPPClause = $clause.CIPPClause -join ' and ' $item | Add-Member -NotePropertyName 'CIPPAlertComment' -NotePropertyValue $clause.AlertComment -Force -ErrorAction SilentlyContinue $item | Add-Member -NotePropertyName 'CIPPCustomSubject' -NotePropertyValue $clause.CustomSubject -Force -ErrorAction SilentlyContinue + $item | Add-Member -NotePropertyName 'CIPPPsaTicketPriority' -NotePropertyValue $clause.PsaTicketPriority -Force -ErrorAction SilentlyContinue $MatchedRules.Add($clause.CIPPClause -join ' and ') $item } diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionMapping.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionMapping.ps1 index 3482998609..090ef6b30d 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionMapping.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionMapping.ps1 @@ -38,10 +38,13 @@ Function Invoke-ExecExtensionMapping { # Outcomes and priorities are scoped to a ticket type. The settings page sends the # ticket type currently selected in the form so the lists follow the dropdown; without # it both fall back to whatever ticket type was last saved. + # @() on each: PowerShell unrolls single-element output, so a ticket type with one outcome + # (or a lookup that answers with a single explanatory row) would otherwise serialise as a + # bare object and break callers that expect a list. $SelectedTicketType = $Request.Query.TicketType - $TicketTypes = Get-HaloTicketType - $Outcomes = Get-HaloTicketOutcome -TicketType $SelectedTicketType - $Priorities = Get-HaloPriority -TicketType $SelectedTicketType + $TicketTypes = @(Get-HaloTicketType) + $Outcomes = @(Get-HaloTicketOutcome -TicketType $SelectedTicketType) + $Priorities = @(Get-HaloPriority -TicketType $SelectedTicketType) $Result = @{ 'TicketTypes' = $TicketTypes 'Outcomes' = $Outcomes diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddAlert.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddAlert.ps1 index 31b38f41bc..89eb2e6b60 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddAlert.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddAlert.ps1 @@ -38,15 +38,18 @@ function Invoke-AddAlert { $Actions = $Request.Body.actions | ConvertTo-Json -Compress -Depth 10 | Out-String $RowKey = $Request.Body.RowKey ? $Request.Body.RowKey : (New-Guid).ToString() $CompleteObject = @{ - Tenants = [string]$TenantsJson - excludedTenants = [string]$excludedTenantsJson - Conditions = [string]$Conditions - Actions = [string]$Actions - type = $Request.Body.logbook.value - RowKey = $RowKey - PartitionKey = 'Webhookv2' - AlertComment = [string]$Request.Body.AlertComment - CustomSubject = [string]$Request.Body.CustomSubject + Tenants = [string]$TenantsJson + excludedTenants = [string]$excludedTenantsJson + Conditions = [string]$Conditions + Actions = [string]$Actions + type = $Request.Body.logbook.value + RowKey = $RowKey + PartitionKey = 'Webhookv2' + AlertComment = [string]$Request.Body.AlertComment + CustomSubject = [string]$Request.Body.CustomSubject + # The audit form posts the raw form values, so an autocomplete selection arrives as a + # {label, value} object - unwrap it to the bare Halo priority id before storing. + PsaTicketPriority = [string]($Request.Body.PsaTicketPriority.value ?? $Request.Body.PsaTicketPriority) } $WebhookTable = Get-CippTable -TableName 'WebhookRules' Add-CIPPAzDataTableEntity @WebhookTable -Entity $CompleteObject -Force diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAlertsQueue.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAlertsQueue.ps1 index 35ec64a84b..83b0379210 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAlertsQueue.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAlertsQueue.ps1 @@ -37,14 +37,15 @@ function Invoke-ListAlertsQueue { AlertComment = $Task.AlertComment CustomSubject = $Task.CustomSubject RawAlert = @{ - Conditions = @($Conditions) - Actions = @($($Task.Actions | ConvertFrom-Json -Depth 10 -ErrorAction SilentlyContinue)) - Tenants = @($Tenants) - type = $Task.type - RowKey = $Task.RowKey - PartitionKey = $Task.PartitionKey - AlertComment = $Task.AlertComment - CustomSubject = $Task.CustomSubject + Conditions = @($Conditions) + Actions = @($($Task.Actions | ConvertFrom-Json -Depth 10 -ErrorAction SilentlyContinue)) + Tenants = @($Tenants) + type = $Task.type + RowKey = $Task.RowKey + PartitionKey = $Task.PartitionKey + AlertComment = $Task.AlertComment + CustomSubject = $Task.CustomSubject + PsaTicketPriority = $Task.PsaTicketPriority } } diff --git a/backend/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 b/backend/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 index deea67be9c..4660215864 100644 --- a/backend/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 +++ b/backend/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 @@ -6,7 +6,11 @@ function New-HaloPSATicket { $client, [string]$UserUPN, [string]$AzureOID, - [string]$DisplayName + [string]$DisplayName, + # Per-alert priority override. Left untyped so callers can hand over either a raw Halo + # priority id or the {label, value} shape the alert form stores, matching how the + # integration-wide DefaultPriority is read below. + $TicketPriority ) #Get HaloPSA Token based on the config we have. $Table = Get-CIPPTable -TableName Extensionsconfig @@ -137,16 +141,29 @@ function New-HaloPSATicket { $TicketType = $Configuration.TicketType.value ?? $Configuration.TicketType $object | Add-Member -MemberType NoteProperty -Name 'tickettype_id' -Value $TicketType -Force } - if ($Configuration.DefaultPriority) { - $Priority = $Configuration.DefaultPriority.value ?? $Configuration.DefaultPriority - $PriorityInt = $Priority -as [int] + # Priority sources in precedence order: the per-alert override configured on the alert, then the + # integration-wide default. Both can be stored as a {label, value} autocomplete object or as a + # raw id depending on where they were saved, so unwrap .value first. A value that isn't a usable + # Halo priority id falls through to the next source rather than failing the ticket - Halo applies + # the SLA default when priority_id is absent. + # + # This only runs on the create path. The ConsolidateTickets note path above returns before here, + # so appending a note to an existing ticket deliberately leaves its priority alone - the same way + # tickettype_id is not re-applied to an existing ticket. + $PrioritySources = @( + @{ Label = 'alert'; Value = ($TicketPriority.value ?? $TicketPriority) } + @{ Label = 'HaloPSA.DefaultPriority'; Value = ($Configuration.DefaultPriority.value ?? $Configuration.DefaultPriority) } + ) + foreach ($Source in $PrioritySources) { + if ([string]::IsNullOrWhiteSpace([string]$Source.Value)) { continue } + $PriorityInt = $Source.Value -as [int] if ($PriorityInt -and $PriorityInt -gt 0) { $object | Add-Member -MemberType NoteProperty -Name 'priority_id' -Value $PriorityInt -Force - } else { - # Stored value isn't a valid Halo priority id (legacy data, hint-row selection, etc.). - # Skip priority_id rather than crashing the cast - Halo will fall back to its default. - Write-LogMessage -message "HaloPSA.DefaultPriority value '$Priority' is not a valid integer - omitting priority_id from ticket payload" -API 'HaloPSATicket' -sev Warning + break } + # Value isn't a valid Halo priority id (legacy data, hint-row selection, etc.). Skip it rather + # than crashing the cast and try the next source. + Write-LogMessage -message "HaloPSA priority value '$($Source.Value)' from $($Source.Label) is not a valid priority id - falling back" -API 'HaloPSATicket' -sev Warning } #use the token to create a new ticket in HaloPSA $body = ConvertTo-Json -Compress -Depth 10 -InputObject @($Object) diff --git a/backend/Modules/CippExtensions/Public/New-CippExtAlert.ps1 b/backend/Modules/CippExtensions/Public/New-CippExtAlert.ps1 index afd594eeff..c5a468edc0 100644 --- a/backend/Modules/CippExtensions/Public/New-CippExtAlert.ps1 +++ b/backend/Modules/CippExtensions/Public/New-CippExtAlert.ps1 @@ -50,6 +50,13 @@ function New-CippExtAlert { if ($Display) { $TicketParams.DisplayName = $Display } } + # Per-alert priority beats the integration-wide DefaultPriority. Unlike the + # user fields above this is NOT gated on LinkTicketsToUsers - priority applies + # to every ticket, and it must also work when no global default is configured. + if ($Alert.PsaTicketPriority) { + $TicketParams.TicketPriority = $Alert.PsaTicketPriority + } + New-HaloPSATicket @TicketParams } } diff --git a/backend/Tests/Extensions/New-HaloPSATicket.Priority.Tests.ps1 b/backend/Tests/Extensions/New-HaloPSATicket.Priority.Tests.ps1 new file mode 100644 index 0000000000..c26cad7767 --- /dev/null +++ b/backend/Tests/Extensions/New-HaloPSATicket.Priority.Tests.ps1 @@ -0,0 +1,126 @@ +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + + function Get-CIPPTable { param($TableName) @{} } + function Get-CIPPAzDataTableEntity { param($Filter, $Property) } + function Add-CIPPAzDataTableEntity { param($Entity, [switch]$Force) } + function Get-HaloToken { param($configuration) } + function Get-HaloUser { param($AzureOID, $Email, $ClientId, $Configuration, $Token) } + function Get-StringHash { param($String) } + function Get-NormalizedError { param($Message) } + function Get-CippException { param($Exception) } + function Write-LogMessage { param($API, $tenant, $message, $sev, $LogData) } + + . (Join-Path $RepoRoot 'Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1') + + # Rebuilds the Extensionsconfig row the function reads on every call. DefaultPriority is the + # integration-wide setting; the per-alert override arrives as the -TicketPriority parameter. + function New-HaloConfigRow { + param($DefaultPriority, [switch]$ConsolidateTickets) + $Halo = @{ + ResourceURL = 'https://halo.example.com/api' + TicketType = 1 + ConsolidateTickets = [bool]$ConsolidateTickets + } + if ($PSBoundParameters.ContainsKey('DefaultPriority')) { $Halo.DefaultPriority = $DefaultPriority } + [pscustomobject]@{ config = (@{ HaloPSA = $Halo } | ConvertTo-Json -Depth 5 -Compress) } + } + + # The ticket payload is only observable as the JSON body handed to Invoke-RestMethod. + function Get-SentTicket { + param($Body) + @($Body | ConvertFrom-Json)[0] + } +} + +Describe 'New-HaloPSATicket priority resolution' { + BeforeEach { + $script:SentBody = $null + + Mock Get-CIPPTable { @{} } + Mock Get-HaloToken { @{ access_token = 'token' } } + Mock Get-StringHash { 'hash' } + Mock Add-CIPPAzDataTableEntity {} + Mock Write-LogMessage {} + Mock Invoke-RestMethod { + $script:SentBody = $Body + @{ id = 42 } + } + } + + Context 'when creating a new ticket' { + BeforeEach { + Mock Get-CIPPAzDataTableEntity { New-HaloConfigRow -DefaultPriority 3 } + } + + It 'uses the per-alert priority over the integration default' { + $null = New-HaloPSATicket -title 'Alert' -description 'Body' -client 1 -TicketPriority 5 + + (Get-SentTicket -Body $script:SentBody).priority_id | Should -Be 5 + } + + It 'unwraps the {label, value} shape saved by the alert form' { + $null = New-HaloPSATicket -title 'Alert' -description 'Body' -client 1 -TicketPriority @{ label = 'Critical'; value = 5 } + + (Get-SentTicket -Body $script:SentBody).priority_id | Should -Be 5 + } + + It 'falls back to the integration default when no per-alert priority is set' { + $null = New-HaloPSATicket -title 'Alert' -description 'Body' -client 1 + + (Get-SentTicket -Body $script:SentBody).priority_id | Should -Be 3 + } + + It 'falls back to the integration default when the per-alert value is empty' { + $null = New-HaloPSATicket -title 'Alert' -description 'Body' -client 1 -TicketPriority '' + + (Get-SentTicket -Body $script:SentBody).priority_id | Should -Be 3 + } + + It 'falls back and warns when the per-alert value is a hint row' { + # -1 is the id Get-HaloPriority uses for its explanatory rows. It casts to a truthy + # int, so only the -gt 0 guard keeps it out of the payload. + $null = New-HaloPSATicket -title 'Alert' -description 'Body' -client 1 -TicketPriority -1 + + (Get-SentTicket -Body $script:SentBody).priority_id | Should -Be 3 + Should -Invoke Write-LogMessage -Times 1 -ParameterFilter { + $sev -eq 'Warning' -and $message -like "*from alert is not a valid priority id*" + } + } + } + + Context 'when neither priority is configured' { + BeforeEach { + Mock Get-CIPPAzDataTableEntity { New-HaloConfigRow } + } + + It 'omits priority_id entirely and logs nothing' { + $null = New-HaloPSATicket -title 'Alert' -description 'Body' -client 1 + + $Ticket = Get-SentTicket -Body $script:SentBody + $Ticket.PSObject.Properties.Name | Should -Not -Contain 'priority_id' + Should -Invoke Write-LogMessage -Times 0 + } + } + + Context 'when consolidating onto an existing open ticket' { + BeforeEach { + Mock Get-CIPPAzDataTableEntity -ParameterFilter { $Filter } { [pscustomobject]@{ TicketID = 99 } } + Mock Get-CIPPAzDataTableEntity -ParameterFilter { -not $Filter } { New-HaloConfigRow -DefaultPriority 3 -ConsolidateTickets } + Mock Invoke-RestMethod -ParameterFilter { $Method -eq 'Get' } { @{ id = 99; hasbeenclosed = $false } } + Mock Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } { + $script:SentBody = $Body + @{ id = 100 } + } + } + + It 'leaves the existing ticket priority alone' { + # Priority is deliberately create-path only - appending a note must not overwrite a + # priority a technician has since changed on the ticket. + $Result = New-HaloPSATicket -title 'Alert' -description 'Body' -client 1 -TicketPriority 5 + + $Result | Should -BeLike 'Note added to ticket in HaloPSA*' + (Get-SentTicket -Body $script:SentBody).PSObject.Properties.Name | Should -Not -Contain 'priority_id' + } + } +} diff --git a/backend/Tests/Webhooks/Test-CIPPAuditLogRules.Tests.ps1 b/backend/Tests/Webhooks/Test-CIPPAuditLogRules.Tests.ps1 index 707ef2a6d3..210b098bb5 100644 --- a/backend/Tests/Webhooks/Test-CIPPAuditLogRules.Tests.ps1 +++ b/backend/Tests/Webhooks/Test-CIPPAuditLogRules.Tests.ps1 @@ -79,21 +79,22 @@ Describe 'Test-CIPPAuditLogRules record shaping' { switch ($TableName) { 'WebhookRules' { [pscustomobject]@{ - PartitionKey = 'WebhookRules' - RowKey = 'rule-1' - Tenants = (@('AllTenants') | ConvertTo-Json -Compress) - excludedTenants = $null - Conditions = (@( + PartitionKey = 'WebhookRules' + RowKey = 'rule-1' + Tenants = (@('AllTenants') | ConvertTo-Json -Compress) + excludedTenants = $null + Conditions = (@( @{ Property = @{ label = 'Operation' } Operator = @{ label = 'eq' } Input = @{ value = 'Set-Mailbox' } } ) | ConvertTo-Json -Compress -Depth 5) - Actions = (@('generatemail') | ConvertTo-Json -Compress) - Type = 'Audit' - AlertComment = 'test comment' - CustomSubject = '' + Actions = (@('generatemail') | ConvertTo-Json -Compress) + Type = 'Audit' + AlertComment = 'test comment' + CustomSubject = '' + PsaTicketPriority = '5' } } 'cacheauditloglookups' { @@ -221,6 +222,9 @@ Describe 'Test-CIPPAuditLogRules record shaping' { $data.CIPPAction | Should -Not -BeNullOrEmpty $data.CIPPClause | Should -Not -BeNullOrEmpty $data.CIPPAlertComment | Should -Be 'test comment' + # Covers the full per-alert priority chain through this function: the config + # projection, the where-clause object, and the stamp onto the matched record. + $data.CIPPPsaTicketPriority | Should -Be '5' } It 'dispatches the matched record to webhook processing' { diff --git a/frontend/src/pages/tenant/administration/alert-configuration/alert.jsx b/frontend/src/pages/tenant/administration/alert-configuration/alert.jsx index 53b58dc39f..d438d66600 100644 --- a/frontend/src/pages/tenant/administration/alert-configuration/alert.jsx +++ b/frontend/src/pages/tenant/administration/alert-configuration/alert.jsx @@ -79,6 +79,60 @@ const AlertWizard = () => { : 'One consolidated ticket per tenant', }, ] + + // The PSA Ticket Priority dropdown is API-backed, so hide it entirely when HaloPSA is off - + // ExecExtensionMapping needs the Extension role that an alert editor may not have, and calling + // it with the integration disabled just returns an error row. PsaTicketStrategy above has static + // options and degrades harmlessly, which is why it is not gated the same way. + const haloEnabled = integrationsConfig?.data?.HaloPSA?.Enabled === true + // Priorities are fetched here rather than by the autocomplete itself for two reasons: the field + // has to react to what Halo returns (a Ticket Type with no SLA has no priorities to offer, and + // the field is shown disabled with the reason instead of an empty dropdown), and loading at page + // level means the list is ready before the field is revealed rather than on first render of it. + // No TicketType param - Get-HaloPriority falls back to the integration's saved ticket type, which + // is the one these tickets will use anyway. + const haloPriorityRequest = ApiGetCall({ + url: '/api/ExecExtensionMapping', + data: { List: 'HaloPSAFields' }, + queryKey: 'HaloPriorities-AlertConfig', + waiting: haloEnabled, + refetchOnMount: false, + refetchOnReconnect: false, + }) + // Get-HaloPriority answers with explanatory rows instead of priorities when it has nothing real + // to offer - a hint row carries priorityid -1, the error row carries no priorityid at all. Those + // are messages, not choices, so they never become options. + // Normalise before use: PowerShell unrolls a single-element array, so an endpoint returning one + // priority (or one hint row) serialises it as a bare object rather than a list. + const haloPriorityRows = [].concat(haloPriorityRequest?.data?.Priorities ?? []) + const psaPriorityOptions = haloPriorityRows + .filter((priority) => Number(priority?.priorityid) > 0) + .map((priority) => ({ value: Number(priority.priorityid), label: priority.name })) + // Settled with nothing pickable, whether that is Halo's own explanatory row (which comes back + // 200 OK) or the request failing outright. Either way there is no choice to offer, so disable + // rather than leave an empty dropdown that looks broken. + const psaPriorityUnavailable = + (haloPriorityRequest.isSuccess || haloPriorityRequest.isError) && + !haloPriorityRequest.isFetching && + psaPriorityOptions.length === 0 + // Prefer Halo's own explanation ("no SLA attached", "select a Ticket Type first") over a generic + // one - it names the thing an admin has to go and fix. + const psaPriorityHelperText = psaPriorityUnavailable + ? `${ + haloPriorityRows.find((priority) => priority?.name)?.name ?? + 'The configured HaloPSA Ticket Type has no priorities to pick from.' + } Tickets from this alert will use the HaloPSA integration default.` + : "Optional. Overrides the HaloPSA Default Priority for tickets raised by this alert. Restricted to the priorities on the integration Ticket Type's SLA. Leave blank to use the integration default." + // Stored as a bare id string on the alert row. Seed the form with {value: } so + // CippAutoComplete's resolvedDefaultValue can swap in the real priority name once the options + // load - it matches on === against a number, so the string form would never resolve. Non-positive + // ids are hint rows saved before they were filtered out; treat them as unset. + const toPsaPriorityValue = (stored) => { + if (stored === undefined || stored === null || stored === '') return null + const numeric = Number(stored) + if (!Number.isFinite(numeric) || numeric <= 0) return null + return { value: numeric, label: String(stored) } + } const [recurrenceOptions, setRecurrenceOptions] = useState([ { value: '30m', label: 'Every 30 minutes' }, { value: '1h', label: 'Every hour' }, @@ -252,6 +306,7 @@ const AlertWizard = () => { CustomSubject: alert.RawAlert.CustomSubject || '', AlertComment: alert.RawAlert.AlertComment || '', PsaTicketStrategy: psaStrategyValue, + PsaTicketPriority: toPsaPriorityValue(alert.RawAlert.PsaTicketPriority), } if (usedCommand?.requiresInput && alert.RawAlert.Parameters) { try { @@ -324,6 +379,7 @@ const AlertWizard = () => { logbook: foundLogbook, AlertComment: alert.RawAlert.AlertComment || '', CustomSubject: alert.RawAlert.CustomSubject || '', + PsaTicketPriority: toPsaPriorityValue(alert.RawAlert.PsaTicketPriority), conditions: [], // Include empty array to register field structure } // Reset first without spawning rows to avoid rendering empty operator fields @@ -560,6 +616,7 @@ const AlertWizard = () => { AlertComment: values.AlertComment, CustomSubject: values.CustomSubject, PsaTicketStrategy: values.PsaTicketStrategy?.value ?? values.PsaTicketStrategy ?? '', + PsaTicketPriority: values.PsaTicketPriority?.value ?? values.PsaTicketPriority ?? '', } apiRequest.mutate( { url: '/api/AddScriptedAlert', data: postObject }, @@ -902,6 +959,29 @@ const AlertWizard = () => { options={actionsToTake} /> + {haloEnabled && ( + + + + + + )} { + {haloEnabled && ( + + + + + + )} + Date: Wed, 5 Aug 2026 14:37:32 +0100 Subject: [PATCH 2/2] fix(halo): send no ticket priority when the ticket type has no SLA Halo priorities are defined per SLA - the same priority_id means a different thing under a different SLA. With no SLA on the configured ticket type there is nothing for the id to resolve against, so New-HaloPSATicket now omits priority_id entirely (per-alert override and integration default alike) and lets HaloPSA apply its own, instead of gambling on whichever SLA Halo picks at creation time. Note this also stops the integration-wide DefaultPriority being sent for SLA-less ticket types, which previously slipped through. The SLA resolution is shared between Get-HaloPriority and New-HaloPSATicket via a new Get-HaloTicketTypeSlaId helper, so the dropdown and the ticket writer cannot disagree about the same ticket type. The lookup only runs when there is a priority to send, so tickets without one cost no extra API call. On the alert page the priority field now stays visible but disabled when there is nothing to pick, showing Halo's own explanation of why and what happens to the tickets, and the priority list refreshes when stale instead of being cached until a hard reload. --- .../Public/Halo/Get-HaloPriority.ps1 | 19 ++----- .../Public/Halo/Get-HaloTicketTypeSlaId.ps1 | 53 +++++++++++++++++++ .../Public/Halo/New-HaloPSATicket.ps1 | 19 ++++++- .../New-HaloPSATicket.Priority.Tests.ps1 | 33 ++++++++++++ .../alert-configuration/alert.jsx | 14 ++--- 5 files changed, 116 insertions(+), 22 deletions(-) create mode 100644 backend/Modules/CippExtensions/Public/Halo/Get-HaloTicketTypeSlaId.ps1 diff --git a/backend/Modules/CippExtensions/Public/Halo/Get-HaloPriority.ps1 b/backend/Modules/CippExtensions/Public/Halo/Get-HaloPriority.ps1 index 5b99bf0dfa..629b97c3c2 100644 --- a/backend/Modules/CippExtensions/Public/Halo/Get-HaloPriority.ps1 +++ b/backend/Modules/CippExtensions/Public/Halo/Get-HaloPriority.ps1 @@ -36,23 +36,14 @@ function Get-HaloPriority { } $Headers = @{ Authorization = "Bearer $($Token.access_token)" } - $TicketTypeRecord = Invoke-RestMethod -Uri "$($Configuration.ResourceURL)/tickettype/$TicketType" -ContentType 'application/json' -Method GET -Headers $Headers - - # Halo's /tickettype/{id} response uses different field names for the linked SLA across - # versions. Check the known variants in priority order, take the first non-zero match. - $SlaIdCandidates = @('default_sla', 'default_sla_id', 'sla_id', 'slaid', 'sla') - $SlaId = $null - foreach ($Field in $SlaIdCandidates) { - $Value = $TicketTypeRecord.$Field - if ($Value -and ([int]$Value) -gt 0) { - $SlaId = [int]$Value - break - } - } + $SlaId = Get-HaloTicketTypeSlaId -TicketType $TicketType -Configuration $Configuration -Token $Token if (-not $SlaId) { + # New-HaloPSATicket applies the same test and omits priority_id entirely for this + # ticket type, so the message describes what will actually happen rather than just + # explaining an empty list. return @(@{ - name = 'The selected Ticket Type has no SLA attached, so there are no priorities to pick from. Attach an SLA to the ticket type in HaloPSA, or leave this blank.' + name = 'The selected Ticket Type has no SLA attached, so there are no priorities to pick from. Tickets will be created without a priority and HaloPSA will apply its own. Attach an SLA to the ticket type in HaloPSA to choose one here.' priorityid = -1 }) } diff --git a/backend/Modules/CippExtensions/Public/Halo/Get-HaloTicketTypeSlaId.ps1 b/backend/Modules/CippExtensions/Public/Halo/Get-HaloTicketTypeSlaId.ps1 new file mode 100644 index 0000000000..1211f59965 --- /dev/null +++ b/backend/Modules/CippExtensions/Public/Halo/Get-HaloTicketTypeSlaId.ps1 @@ -0,0 +1,53 @@ +function Get-HaloTicketTypeSlaId { + <# + .SYNOPSIS + Resolve the SLA id attached to a HaloPSA ticket type, or $null when it has none. + .DESCRIPTION + Priorities in HaloPSA are defined per priority per SLA - the same priority_id means a + different thing under a different SLA (response and resolution targets are set on the + SLA/priority pair). A ticket type with no SLA therefore has no priority set that can be + meaningfully chosen from, which is why both the settings dropdown and the ticket writer + need to agree on whether one is attached. + + Shared by Get-HaloPriority (to decide whether there is anything to offer) and + New-HaloPSATicket (to decide whether to send priority_id at all), so the two cannot drift + apart and start disagreeing about the same ticket type. + .PARAMETER TicketType + The ticket type id to resolve. + .PARAMETER Configuration + The HaloPSA extension configuration, for ResourceURL. + .PARAMETER Token + An existing Halo token, so callers that already hold one do not fetch a second. + .OUTPUTS + [int] the SLA id, or $null when the ticket type has no SLA or could not be read. + #> + [CmdletBinding()] + param ( + $TicketType, + $Configuration, + $Token + ) + + if (-not $TicketType) { return $null } + + try { + $Headers = @{ Authorization = "Bearer $($Token.access_token)" } + $TicketTypeRecord = Invoke-RestMethod -Uri "$($Configuration.ResourceURL)/tickettype/$TicketType" -ContentType 'application/json' -Method GET -Headers $Headers + + # Halo's /tickettype/{id} response uses different field names for the linked SLA across + # versions. Check the known variants in order and take the first usable match. Halo uses + # -1 for "none", so anything not greater than zero counts as no SLA. + foreach ($Field in @('default_sla', 'default_sla_id', 'sla_id', 'slaid', 'sla')) { + $Value = $TicketTypeRecord.$Field + if ($Value -and ([int]$Value) -gt 0) { + return [int]$Value + } + } + return $null + } catch { + # Callers treat $null as "no SLA" and omit the priority, which is the safe direction: + # a transient lookup failure should not put an arbitrary priority on a ticket. + Write-Information "Could not resolve the SLA for HaloPSA ticket type $TicketType : $($_.Exception.Message)" + return $null + } +} diff --git a/backend/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 b/backend/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 index 4660215864..ccaf19a1a6 100644 --- a/backend/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 +++ b/backend/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 @@ -154,17 +154,34 @@ function New-HaloPSATicket { @{ Label = 'alert'; Value = ($TicketPriority.value ?? $TicketPriority) } @{ Label = 'HaloPSA.DefaultPriority'; Value = ($Configuration.DefaultPriority.value ?? $Configuration.DefaultPriority) } ) + $ResolvedPriority = $null + $PrioritySource = $null foreach ($Source in $PrioritySources) { if ([string]::IsNullOrWhiteSpace([string]$Source.Value)) { continue } $PriorityInt = $Source.Value -as [int] if ($PriorityInt -and $PriorityInt -gt 0) { - $object | Add-Member -MemberType NoteProperty -Name 'priority_id' -Value $PriorityInt -Force + $ResolvedPriority = $PriorityInt + $PrioritySource = $Source.Label break } # Value isn't a valid Halo priority id (legacy data, hint-row selection, etc.). Skip it rather # than crashing the cast and try the next source. Write-LogMessage -message "HaloPSA priority value '$($Source.Value)' from $($Source.Label) is not a valid priority id - falling back" -API 'HaloPSATicket' -sev Warning } + + # A priority id only means something within an SLA - the same id maps to a different priority + # under a different SLA. When the ticket type has no SLA there is nothing for it to resolve + # against, so send no priority and let Halo apply its own rather than gambling on whichever SLA + # it happens to pick. This is the same test Get-HaloPriority uses to decide it has nothing to + # offer, so a priority can never be sent that the settings page would not have let you choose. + # Only checked when there is a priority to send, so the common path costs no extra API call. + if ($ResolvedPriority) { + if (Get-HaloTicketTypeSlaId -TicketType ($Configuration.TicketType.value ?? $Configuration.TicketType) -Configuration $Configuration -Token $token) { + $object | Add-Member -MemberType NoteProperty -Name 'priority_id' -Value $ResolvedPriority -Force + } else { + Write-Information "Ticket type has no SLA attached - omitting priority_id ($ResolvedPriority from $PrioritySource) so HaloPSA applies its own priority" + } + } #use the token to create a new ticket in HaloPSA $body = ConvertTo-Json -Compress -Depth 10 -InputObject @($Object) diff --git a/backend/Tests/Extensions/New-HaloPSATicket.Priority.Tests.ps1 b/backend/Tests/Extensions/New-HaloPSATicket.Priority.Tests.ps1 index c26cad7767..9e1b859ecb 100644 --- a/backend/Tests/Extensions/New-HaloPSATicket.Priority.Tests.ps1 +++ b/backend/Tests/Extensions/New-HaloPSATicket.Priority.Tests.ps1 @@ -5,6 +5,7 @@ BeforeAll { function Get-CIPPAzDataTableEntity { param($Filter, $Property) } function Add-CIPPAzDataTableEntity { param($Entity, [switch]$Force) } function Get-HaloToken { param($configuration) } + function Get-HaloTicketTypeSlaId { param($TicketType, $Configuration, $Token) } function Get-HaloUser { param($AzureOID, $Email, $ClientId, $Configuration, $Token) } function Get-StringHash { param($String) } function Get-NormalizedError { param($Message) } @@ -39,6 +40,9 @@ Describe 'New-HaloPSATicket priority resolution' { Mock Get-CIPPTable { @{} } Mock Get-HaloToken { @{ access_token = 'token' } } + # Ticket type has an SLA unless a test says otherwise - priority is only sent when one is + # attached, because a priority id is meaningless outside the SLA that defines it. + Mock Get-HaloTicketTypeSlaId { 1 } Mock Get-StringHash { 'hash' } Mock Add-CIPPAzDataTableEntity {} Mock Write-LogMessage {} @@ -89,6 +93,35 @@ Describe 'New-HaloPSATicket priority resolution' { } } + Context 'when the ticket type has no SLA' { + BeforeEach { + Mock Get-CIPPAzDataTableEntity { New-HaloConfigRow -DefaultPriority 3 } + Mock Get-HaloTicketTypeSlaId { $null } + } + + It 'omits priority_id even when the alert asks for one' { + # A priority id resolves against an SLA, so with none attached there is nothing for it + # to mean. Halo applies its own priority instead of us gambling on the SLA it picks. + $null = New-HaloPSATicket -title 'Alert' -description 'Body' -client 1 -TicketPriority 5 + + (Get-SentTicket -Body $script:SentBody).PSObject.Properties.Name | Should -Not -Contain 'priority_id' + } + + It 'omits priority_id when only the integration default is set' { + $null = New-HaloPSATicket -title 'Alert' -description 'Body' -client 1 + + (Get-SentTicket -Body $script:SentBody).PSObject.Properties.Name | Should -Not -Contain 'priority_id' + } + + It 'does not look up the SLA when there is no priority to send' { + Mock Get-CIPPAzDataTableEntity { New-HaloConfigRow } + + $null = New-HaloPSATicket -title 'Alert' -description 'Body' -client 1 + + Should -Invoke Get-HaloTicketTypeSlaId -Times 0 + } + } + Context 'when neither priority is configured' { BeforeEach { Mock Get-CIPPAzDataTableEntity { New-HaloConfigRow } diff --git a/frontend/src/pages/tenant/administration/alert-configuration/alert.jsx b/frontend/src/pages/tenant/administration/alert-configuration/alert.jsx index d438d66600..474894cb42 100644 --- a/frontend/src/pages/tenant/administration/alert-configuration/alert.jsx +++ b/frontend/src/pages/tenant/administration/alert-configuration/alert.jsx @@ -91,13 +91,14 @@ const AlertWizard = () => { // level means the list is ready before the field is revealed rather than on first render of it. // No TicketType param - Get-HaloPriority falls back to the integration's saved ticket type, which // is the one these tickets will use anyway. + // Default refetch-on-mount is kept (unlike the integrations config above): nothing invalidates + // this query key when the integration's Ticket Type changes, so remounting the page is the only + // moment stale priorities can catch up with the integration settings. const haloPriorityRequest = ApiGetCall({ url: '/api/ExecExtensionMapping', data: { List: 'HaloPSAFields' }, queryKey: 'HaloPriorities-AlertConfig', waiting: haloEnabled, - refetchOnMount: false, - refetchOnReconnect: false, }) // Get-HaloPriority answers with explanatory rows instead of priorities when it has nothing real // to offer - a hint row carries priorityid -1, the error row carries no priorityid at all. Those @@ -116,12 +117,11 @@ const AlertWizard = () => { !haloPriorityRequest.isFetching && psaPriorityOptions.length === 0 // Prefer Halo's own explanation ("no SLA attached", "select a Ticket Type first") over a generic - // one - it names the thing an admin has to go and fix. + // one - it names the thing an admin has to go and fix, and already states what happens to the + // tickets. The generic fallback only shows when the request itself failed and no rows came back. const psaPriorityHelperText = psaPriorityUnavailable - ? `${ - haloPriorityRows.find((priority) => priority?.name)?.name ?? - 'The configured HaloPSA Ticket Type has no priorities to pick from.' - } Tickets from this alert will use the HaloPSA integration default.` + ? (haloPriorityRows.find((priority) => priority?.name)?.name ?? + 'Could not load HaloPSA priorities, so none can be chosen here. Tickets from this alert will be created without a per-alert priority.') : "Optional. Overrides the HaloPSA Default Priority for tickets raised by this alert. Restricted to the priorities on the integration Ticket Type's SLA. Leave blank to use the integration default." // Stored as a bare id string on the alert row. Seed the form with {value: } so // CippAutoComplete's resolvedDefaultValue can swap in the real priority name once the options