Skip to content

Commit b95748c

Browse files
authored
Merge pull request #1078 from KelvinTegelaar/dev
[pull] dev from KelvinTegelaar:dev
2 parents 67df3ba + 23502b9 commit b95748c

1 file changed

Lines changed: 115 additions & 20 deletions

File tree

Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1

Lines changed: 115 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,47 @@ function Send-CIPPScheduledTaskAlert {
3636
$Attachments
3737
)
3838

39+
function Format-AlertCellValue {
40+
<#
41+
ConvertTo-Html stringifies non-primitive property values with .ToString(),
42+
which renders nested collections/objects as .NET type names like
43+
'System.Collections.Generic.List`1[System.Object]'. Flatten them to
44+
readable text instead. '[[BR]]' survives ConvertTo-Html's HTML encoding
45+
and is swapped for <br /> after the fragment is generated.
46+
#>
47+
param($Value, [int]$Depth = 0)
48+
if ($null -eq $Value) { return '' }
49+
if ($Value -is [string]) { return $Value }
50+
if ($Value -is [datetime]) { return $Value.ToString('yyyy-MM-dd HH:mm:ss') }
51+
if ($Value -is [bool] -or $Value.GetType().IsPrimitive -or $Value -is [decimal]) { return "$Value" }
52+
if ($Depth -ge 3) { return "$(try { $Value | ConvertTo-Json -Compress -Depth 5 } catch { $Value })" }
53+
if ($Value -is [System.Collections.IDictionary]) {
54+
return (@($Value.GetEnumerator() | ForEach-Object { "$($_.Key): $(Format-AlertCellValue -Value $_.Value -Depth ($Depth + 1))" }) -join '[[BR]]')
55+
}
56+
if ($Value -is [System.Collections.IEnumerable]) {
57+
return (@($Value | ForEach-Object { Format-AlertCellValue -Value $_ -Depth ($Depth + 1) }) -join '[[BR]]')
58+
}
59+
$Props = @($Value.PSObject.Properties)
60+
if ($Props.Count -gt 0) {
61+
return (@($Props | ForEach-Object { "$($_.Name): $(Format-AlertCellValue -Value $_.Value -Depth ($Depth + 1))" }) -join '[[BR]]')
62+
}
63+
return "$Value"
64+
}
65+
66+
function ConvertTo-AlertDisplayRow {
67+
# Normalizes a result row for ConvertTo-Html: hashtables become objects (so they
68+
# render as columns instead of Keys/Values/Count) and every value is flattened.
69+
param($Row)
70+
if ($null -eq $Row -or $Row -is [string]) { return $Row }
71+
$Display = [ordered]@{}
72+
if ($Row -is [System.Collections.IDictionary]) {
73+
foreach ($Key in $Row.Keys) { $Display[[string]$Key] = Format-AlertCellValue -Value $Row[$Key] -Depth 1 }
74+
} else {
75+
foreach ($Prop in $Row.PSObject.Properties) { $Display[$Prop.Name] = Format-AlertCellValue -Value $Prop.Value -Depth 1 }
76+
}
77+
[pscustomobject]$Display
78+
}
79+
3980
try {
4081
Write-Information "Sending post-execution alerts for task $($TaskInfo.Name)"
4182

@@ -56,12 +97,15 @@ function Send-CIPPScheduledTaskAlert {
5697

5798
# Build HTML with adaptive table styling
5899
$TableDesign = '<style>table.adaptiveTable{border:1px solid currentColor;background-color:transparent;width:100%;text-align:left;border-collapse:collapse;opacity:0.9}table.adaptiveTable td,table.adaptiveTable th{border:1px solid currentColor;padding:8px 6px;opacity:0.8}table.adaptiveTable tbody td{font-size:13px}table.adaptiveTable tr:nth-child(even){background-color:rgba(128,128,128,0.1)}table.adaptiveTable thead{background-color:rgba(128,128,128,0.2);border-bottom:2px solid currentColor}table.adaptiveTable thead th{font-size:15px;font-weight:700;border-left:1px solid currentColor}table.adaptiveTable thead th:first-child{border-left:none}table.adaptiveTable tfoot{font-size:14px;font-weight:700;background-color:rgba(128,128,128,0.1);border-top:2px solid currentColor}table.adaptiveTable tfoot td{font-size:14px}@media (prefers-color-scheme: dark){table.adaptiveTable{opacity:0.95}table.adaptiveTable tr:nth-child(even){background-color:rgba(255,255,255,0.05)}table.adaptiveTable thead{background-color:rgba(255,255,255,0.1)}table.adaptiveTable tfoot{background-color:rgba(255,255,255,0.05)}}</style>'
100+
$EncodedTaskName = [System.Web.HttpUtility]::HtmlEncode($TaskInfo.Name)
101+
$EncodedTenantName = [System.Web.HttpUtility]::HtmlEncode($TenantFilter)
102+
$AlertHeader = "<div style=`"margin:0 0 14px;`"><p style=`"margin:0 0 2px;font-size:15px;font-weight:600;`">$EncodedTaskName</p><p style=`"margin:0;font-size:13px;opacity:0.75;`">Tenant: <strong>$EncodedTenantName</strong></p></div>"
59103
$FinalResults = if ($Results -is [array] -and $Results[0] -is [string]) {
60104
$Results | ConvertTo-Html -Fragment -Property @{ l = 'Text'; e = { $_ } }
61105
} else {
62-
$Results | ConvertTo-Html -Fragment
106+
$Results | ForEach-Object { ConvertTo-AlertDisplayRow -Row $_ } | ConvertTo-Html -Fragment
63107
}
64-
$HTML = $FinalResults -replace '<table>', "This alert is for tenant $TenantFilter. <br /><br /> $TableDesign<table class=adaptiveTable>" | Out-String
108+
$HTML = $FinalResults -replace '\[\[BR\]\]', '<br />' -replace '<table>', "$AlertHeader $TableDesign<table class=adaptiveTable>" | Out-String
65109

66110
# For alert tasks, add per-row snooze links to the email
67111
if ($TaskType -eq 'Alert' -and $Results -is [array] -and $Results.Count -gt 0 -and $Results[0] -isnot [string]) {
@@ -147,24 +191,70 @@ function Send-CIPPScheduledTaskAlert {
147191
switch -wildcard ($TaskInfo.PostExecution) {
148192
'*psa*' {
149193
$PsaSplitSent = $false
150-
if ($TaskType -eq 'Alert' -and $Results -is [array] -and $Results.Count -gt 0 -and $Results[0] -isnot [string]) {
151-
try {
152-
$ExtConfigTable = Get-CIPPTable -TableName Extensionsconfig
153-
$ExtConfig = (Get-CIPPAzDataTableEntity @ExtConfigTable).config | ConvertFrom-Json -ErrorAction SilentlyContinue
154-
$HaloConfig = $ExtConfig.HaloPSA
155-
156-
# Per-task PsaTicketStrategy (configured on the alert) overrides the global
157-
# HaloPSA.LinkTicketsToUsers toggle. Lets MSPs decide on a per-alert basis
158-
# whether a wide alert (e.g. "users without MFA") should produce one ticket
159-
# per user or one consolidated ticket per tenant.
194+
$TaskAffectedUser = $null
195+
try {
196+
$ExtConfigTable = Get-CIPPTable -TableName Extensionsconfig
197+
$ExtConfig = (Get-CIPPAzDataTableEntity @ExtConfigTable).config | ConvertFrom-Json -ErrorAction SilentlyContinue
198+
$HaloConfig = $ExtConfig.HaloPSA
199+
200+
if ($HaloConfig -and $HaloConfig.Enabled) {
201+
# Resolve an affected user from the scheduled task's own parameters first.
202+
# User-targeted tasks (Edit user, license changes, sign-in state) carry the
203+
# user in Parameters while their results often have no UPN column. A UPN-like
204+
# value maps to UPN, a GUID to AzureOID; New-CippExtAlert resolves the rest
205+
# via Graph. Placeholder values (%userid%) from trigger tasks are skipped.
206+
$UserUpn = $null
207+
$UserOid = $null
208+
$UserDisplay = $null
209+
$GuidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
210+
211+
# Add/Edit user tasks nest the full target user in a UserObj parameter.
212+
# The UPN is either stored directly or composed from username + domain
213+
# (matching how New-CIPPUser/Set-CIPPUser build it).
214+
$UserObj = $TaskParameters.UserObj
215+
if ($UserObj) {
216+
if ($UserObj.userPrincipalName -is [string] -and $UserObj.userPrincipalName -match '@') {
217+
$UserUpn = $UserObj.userPrincipalName
218+
} elseif ($UserObj.username -is [string] -and -not [string]::IsNullOrWhiteSpace($UserObj.username)) {
219+
$UserDomain = if ($UserObj.Domain -is [string] -and $UserObj.Domain) { $UserObj.Domain } else { $UserObj.primDomain.value }
220+
if ($UserDomain -is [string] -and -not [string]::IsNullOrWhiteSpace($UserDomain)) { $UserUpn = "$($UserObj.username)@$UserDomain" }
221+
}
222+
if ($UserObj.id -is [string] -and $UserObj.id -match $GuidPattern) { $UserOid = $UserObj.id }
223+
if ($UserObj.displayName -is [string] -and -not [string]::IsNullOrWhiteSpace($UserObj.displayName)) { $UserDisplay = $UserObj.displayName }
224+
}
225+
226+
if (-not $UserUpn -and -not $UserOid) {
227+
$ParamKeys = @('UserPrincipalName', 'UPN', 'username', 'user', 'userid', 'id')
228+
foreach ($ParamKey in $ParamKeys) {
229+
$ParamValue = $TaskParameters.$ParamKey
230+
# Form fields store autocomplete selections as {label, value} objects.
231+
if ($ParamValue -isnot [string] -and $null -ne $ParamValue.value -and $ParamValue.value -is [string]) { $ParamValue = $ParamValue.value }
232+
if ($ParamValue -isnot [string] -or [string]::IsNullOrWhiteSpace($ParamValue) -or $ParamValue -match '%') { continue }
233+
if (-not $UserUpn -and $ParamValue -match '@') { $UserUpn = $ParamValue }
234+
elseif (-not $UserOid -and $ParamValue -match $GuidPattern) { $UserOid = $ParamValue }
235+
}
236+
}
237+
238+
if ($UserUpn -or $UserOid) {
239+
$TaskAffectedUser = [pscustomobject]@{
240+
UPN = $UserUpn
241+
AzureOID = $UserOid
242+
DisplayName = $UserDisplay
243+
}
244+
}
245+
246+
# Per-task PsaTicketStrategy (configured on the task) overrides the global
247+
# HaloPSA.LinkTicketsToUsers toggle. Lets MSPs decide on a per-task basis
248+
# whether a wide result set (e.g. "users without MFA") should produce one
249+
# ticket per user or one consolidated ticket per tenant.
160250
$TaskStrategy = $TaskInfo.PsaTicketStrategy
161251
$ShouldSplit = switch ($TaskStrategy) {
162252
'split' { $true }
163253
'consolidated' { $false }
164254
default { [bool]$HaloConfig.LinkTicketsToUsers }
165255
}
166256

167-
if ($HaloConfig -and $HaloConfig.Enabled -and $ShouldSplit) {
257+
if ($ShouldSplit -and $Results -is [array] -and $Results.Count -gt 0 -and $Results[0] -isnot [string]) {
168258
$UpnFieldCandidates = @('UserPrincipalName', 'userPrincipalName', 'UPN', 'userId', 'Userkey')
169259
$RowProperties = $Results[0].PSObject.Properties.Name
170260
$UpnField = $UpnFieldCandidates | Where-Object { $_ -in $RowProperties } | Select-Object -First 1
@@ -177,12 +267,15 @@ function Send-CIPPScheduledTaskAlert {
177267

178268
foreach ($Group in $Groups) {
179269
$GroupKey = $Group.Name
180-
$GroupHTMLFragment = $Group.Group | ConvertTo-Html -Fragment
181-
$GroupHTML = $GroupHTMLFragment -replace '<table>', "This alert is for tenant $TenantFilter. <br /><br /> $TableDesign<table class=adaptiveTable>" | Out-String
270+
$GroupHTMLFragment = $Group.Group | ForEach-Object { ConvertTo-AlertDisplayRow -Row $_ } | ConvertTo-Html -Fragment
271+
$GroupHTML = $GroupHTMLFragment -replace '\[\[BR\]\]', '<br />' -replace '<table>', "$AlertHeader $TableDesign<table class=adaptiveTable>" | Out-String
182272

183273
if ([string]::IsNullOrWhiteSpace($GroupKey)) {
184-
# Rows without a usable user identifier - send as a single tenant-scoped ticket.
185-
Send-CIPPAlert -Type 'psa' -Title $title -HTMLContent $GroupHTML -TenantFilter $TenantFilter
274+
# Rows without a usable user identifier - fall back to the
275+
# task-level affected user if one was resolved.
276+
$GroupParams = @{ Type = 'psa'; Title = $title; HTMLContent = $GroupHTML; TenantFilter = $TenantFilter }
277+
if ($TaskAffectedUser) { $GroupParams.AffectedUser = $TaskAffectedUser }
278+
Send-CIPPAlert @GroupParams
186279
} else {
187280
$GroupDisplayName = if ($DisplayField) { $Group.Group[0].$DisplayField } else { $null }
188281
$UserLabel = if ($GroupDisplayName) { "$GroupDisplayName ($GroupKey)" } else { $GroupKey }
@@ -197,13 +290,15 @@ function Send-CIPPScheduledTaskAlert {
197290
$PsaSplitSent = $true
198291
}
199292
}
200-
} catch {
201-
Write-Information "Failed to split PSA alert by user, falling back to consolidated ticket: $($_.Exception.Message)"
202293
}
294+
} catch {
295+
Write-Information "Failed to resolve PSA affected user or split by user, falling back to consolidated ticket: $($_.Exception.Message)"
203296
}
204297

205298
if (-not $PsaSplitSent) {
206-
Send-CIPPAlert -Type 'psa' -Title $title -HTMLContent $HTML -TenantFilter $TenantFilter
299+
$PsaParams = @{ Type = 'psa'; Title = $title; HTMLContent = $HTML; TenantFilter = $TenantFilter }
300+
if ($TaskAffectedUser) { $PsaParams.AffectedUser = $TaskAffectedUser }
301+
Send-CIPPAlert @PsaParams
207302
}
208303
}
209304
'*email*' {

0 commit comments

Comments
 (0)