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/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 deea67be9c..ccaf19a1a6 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,15 +141,45 @@ 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) } + ) + $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 { - # 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 + 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 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..9e1b859ecb --- /dev/null +++ b/backend/Tests/Extensions/New-HaloPSATicket.Priority.Tests.ps1 @@ -0,0 +1,159 @@ +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-HaloTicketTypeSlaId { param($TicketType, $Configuration, $Token) } + 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' } } + # 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 {} + 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 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 } + } + + 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..474894cb42 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. + // 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, + }) + // 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, 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 ?? + '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 + // 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 && ( + + + + + + )} +