Public/Send-NSPScanBridgeAlert.ps1

function Send-NSPScanBridgeAlert {
    <#
    .SYNOPSIS
        Escalates unresolved ScanBridge health conditions to an MSP ticket mailbox.
    .DESCRIPTION
        Uses NSP.M365.Graph Send-NSPGraphMail. No PDFs, paths, patient names or
        source filenames are included. One persistent alert marker per route and
        condition set suppresses repeated tickets. An alert is only marked sent
        after Graph accepts the sendMail request. Graph acceptance is not proof
        of inbox delivery.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)][string]$ConfigurationPath,
        [ValidateRange(1,100000)][int]$MaximumPending = 25,
        [ValidateRange(1,10080)][int]$MaximumPendingMinutes = 10,
        [switch]$WorkerFailed,
        [switch]$WorkerSucceeded
    )
    if ($WorkerFailed -and $WorkerSucceeded) { throw 'Worker status flags are mutually exclusive.' }
    $config = Import-PowerShellDataFile -LiteralPath $ConfigurationPath -ErrorAction Stop
    if (-not $config.Alerts -or -not $config.Alerts.Enabled) { throw 'Alerting must be explicitly enabled.' }
    $alerts = $config.Alerts
    foreach ($field in 'From','To','StateDirectory') {
        if (-not $alerts[$field]) { throw "Missing Alerts.$field." }
    }
    if ($alerts.To -notmatch '^[A-Za-z0-9.!%+_\-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$') {
        throw 'Invalid alert recipient address.'
    }
    if (-not (Test-Path -LiteralPath $alerts.StateDirectory -PathType Container)) {
        throw 'Alert state directory must already exist.'
    }
    $warningDays = 30
    if ($alerts.ContainsKey('CertificateWarningDays')) {
        $warningDays = $alerts.CertificateWarningDays -as [int]
        if ($null -eq $warningDays -or $warningDays -lt 1 -or $warningDays -gt 365) { throw 'Alerts.CertificateWarningDays must be 1 to 365.' }
    }
    $routeNames = @($config.Routes | ForEach-Object { [string]$_.Name })
    if (@($routeNames | Where-Object { $_ -in @('System','Worker','Certificates') }).Count -gt 0 -or
        @($routeNames | Group-Object | Where-Object Count -gt 1).Count -gt 0) {
        throw 'Alert routes require unique names excluding the reserved System, Worker and Certificates names.'
    }
    $alertLock = $null
    try {
        $alertLock = [IO.File]::Open((Join-Path $alerts.StateDirectory 'alerts.lock'), [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None)
    } catch [IO.IOException] { throw 'Another ScanBridge alert process holds the lock.' }
    try {
        if (-not $alerts.Auth -or -not $alerts.Auth.TenantId -or -not $alerts.Auth.ClientId -or -not $alerts.Auth.CertificateThumbprint) {
            throw 'Alerts.Auth requires a dedicated app-only mail identity.'
        }
        if (-not $WhatIfPreference) { Connect-NSPScanBridgeContext -Auth $alerts.Auth }
        $results = @()
        try {
            $results = @(Test-NSPScanBridgeHealth -ConfigurationPath $ConfigurationPath -MaximumPending $MaximumPending -MaximumPendingMinutes $MaximumPendingMinutes)
            if ($results.Count -eq 0) { throw 'No routes returned health.' }
            $results += [pscustomobject]@{ Route = 'System'; Healthy = $true; Conditions = @(); PendingCount = 0; OldestPendingMinutes = $null }
        } catch {
            # Never leak the exception message into the ticket; it may contain a private path.
            $results = @([pscustomobject]@{
                Route = 'System'
                Healthy = $false
                Conditions = @('HealthCheckFailed')
                PendingCount = -1
                OldestPendingMinutes = $null
            })
        }
        if ($WorkerFailed -or $WorkerSucceeded) {
        $results += [pscustomobject]@{
            Route = 'Worker'
            Healthy = (-not $WorkerFailed)
            Conditions = $(if ($WorkerFailed) { @('WorkerFailure') } else { @() })
            PendingCount = 0
            OldestPendingMinutes = $null
        }
        }
        # Proactive notice: the ingestion or alert certificate is near or past expiry.
        $certificateConditions = @()
        $certificateDays = $null
        try {
            foreach ($identity in @(@{ Name = 'Ingestion'; Auth = $config.Auth }, @{ Name = 'Alert'; Auth = $alerts.Auth })) {
                if (-not $identity.Auth -or -not $identity.Auth.CertificateThumbprint) { continue }
                $status = Get-NSPScanBridgeCertificateStatus -Thumbprint ([string]$identity.Auth.CertificateThumbprint) -WarningDays $warningDays -ErrorAction Stop
                if ($status.Status -ne 'Valid') { $certificateConditions += ($identity.Name + 'Certificate' + $status.Status) }
                if ($null -ne $status.DaysRemaining -and ($null -eq $certificateDays -or $status.DaysRemaining -lt $certificateDays)) {
                    $certificateDays = $status.DaysRemaining
                }
            }
        } catch {
            $certificateConditions = @('CertificateCheckFailed')
        }
        $results += [pscustomobject]@{
            Route = 'Certificates'
            Healthy = ($certificateConditions.Count -eq 0)
            Conditions = $certificateConditions
            PendingCount = 0
            OldestPendingMinutes = $null
            CertificateDaysRemaining = $certificateDays
        }
        foreach ($result in $results) {
            $routeId = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$result.Route))
            $routeId = $routeId.TrimEnd('=').Replace('+','-').Replace('/','_')
            $marker = Join-Path $alerts.StateDirectory ($routeId + '.alert.json')
            if ($result.Healthy) {
                if (Test-Path -LiteralPath $marker -PathType Leaf) {
                    $prior = Get-Content -LiteralPath $marker -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
                    if ($prior.Active -ne $false -and $PSCmdlet.ShouldProcess('Alert marker', 'Record recovered health')) {
                        $prior | Add-Member -NotePropertyName Active -NotePropertyValue $false -Force
                        $prior | Add-Member -NotePropertyName ResolvedUtc -NotePropertyValue ([DateTime]::UtcNow.ToString('o')) -Force
                        $resolvedTemp = $marker + '.' + [guid]::NewGuid().ToString('N') + '.tmp'
                        try {
                            [IO.File]::WriteAllText($resolvedTemp, ($prior | ConvertTo-Json -Depth 5))
                            [IO.File]::Replace($resolvedTemp, $marker, [NullString]::Value)
                        } finally {
                            if (Test-Path -LiteralPath $resolvedTemp) { Remove-Item -LiteralPath $resolvedTemp -Force }
                        }
                    }
                }
                continue
            }
            $conditions = @($result.Conditions | Sort-Object -Unique)
            $signature = $conditions -join ','
            $existing = $null
            if (Test-Path -LiteralPath $marker -PathType Leaf) {
                $existing = Get-Content -LiteralPath $marker -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
            }
            if ($existing -and $existing.Active -ne $false -and $existing.Signature -eq $signature) { continue }
            if ($result.Route -eq 'Certificates') {
                $subject = '[NSP.ScanBridge] Certificate renewal required'
                $body = @(
                    'A ScanBridge app-only certificate is missing, expired or near expiry.'
                    ('Condition codes: {0}' -f $signature)
                    ('Fewest days remaining: {0}' -f $result.CertificateDaysRemaining)
                    'Renew the certificate on the ScanBridge host and update the app registration and settings.'
                    'No document content or filenames are included.'
                ) -join [Environment]::NewLine
            } else {
                $subject = '[NSP.ScanBridge] Manual intervention required'
                $body = @(
                    'ScanBridge detected an unresolved delivery condition.'
                    ('Route: {0}' -f $result.Route)
                    ('Condition codes: {0}' -f $signature)
                    ('Pending documents: {0}' -f $result.PendingCount)
                    ('Oldest pending (minutes): {0}' -f $result.OldestPendingMinutes)
                    'Investigate the restricted local queue and receipts.'
                    'No document content or filenames are included.'
                ) -join [Environment]::NewLine
            }
            if ($PSCmdlet.ShouldProcess($alerts.To, 'Send ScanBridge ticket escalation')) {
                Send-NSPGraphMail -From $alerts.From -To @($alerts.To) -Subject $subject -Body $body -ErrorAction Stop
                $temp = $marker + '.' + [guid]::NewGuid().ToString('N') + '.tmp'
                $json = @{ Signature = $signature; Active = $true; SentUtc = [DateTime]::UtcNow.ToString('o') } | ConvertTo-Json
                try {
                    [IO.File]::WriteAllText($temp, $json)
                    if (Test-Path -LiteralPath $marker) {
                        [IO.File]::Replace($temp, $marker, [NullString]::Value)
                    } else {
                        [IO.File]::Move($temp, $marker)
                    }
                } finally {
                    if (Test-Path -LiteralPath $temp) { Remove-Item -LiteralPath $temp -Force }
                }
                [pscustomobject]@{ Route = $result.Route; Conditions = $signature; AlertSubmitted = $true }
            }
        }
    } finally { $alertLock.Dispose() }
}