PnpAlertsExport.psm1

<#
.SYNOPSIS
    Export SharePoint Online alerts to a CSV file using app‑only authentication.
 
.DESCRIPTION
    Retrieves alert subscriptions across all site collections or a single site
    (requires -SiteUrl) and their subsites, exporting them to a CSV file.
 
    - Interactive console wizard guides you step by step—no need to memorize long
      parameter strings.
    - Choose to auto‑register a new Entra AD app or supply an existing AppId/Tenant/PFX.
    - Process all sites or just one, scan all document libraries or one specific.
    - Parallel mode can dramatically speed up very large‑tenant scans by processing
      multiple runspaces simultaneously.
    - Supports PowerShell 7+ and PnP.PowerShell v3.1.0.
 
.PARAMETER AdminCenterUrl
    The URL of the SharePoint Admin Center (e.g., https://contoso-admin.sharepoint.com).
 
.PARAMETER OutputCsvPath
    Full path where the resulting CSV file will be saved.
 
.PARAMETER Mode
    Scope of sites to process: AllSites or SingleSite.
 
.PARAMETER SiteUrl
    The URL of the site to process when Mode is SingleSite.
 
.PARAMETER ListMode
    Which libraries to scan: AllLibraries or SpecificLibrary.
 
.PARAMETER ListTitle
    Title of the library to scan when ListMode is SpecificLibrary.
 
.PARAMETER UseParallel
    Switch to enable parallel processing for faster scans of large tenants.
 
.PARAMETER NoSubWebs
    Switch to skip subsites entirely.
 
.PARAMETER AppId
    Azure AD application ID for an existing app (required when using existing).
 
.PARAMETER Tenant
    Your tenant domain or ID (e.g., contoso.onmicrosoft.com).
 
.PARAMETER CertificatePath
    Path to the .pfx certificate file for app‑only authentication.
 
.PARAMETER CertificatePassword
    SecureString password to unlock the .pfx file.
 
.PARAMETER AutoRegister
    Switch to auto‑register a new Entra AD application.
 
.PARAMETER AppName
    Display name for the new Entra AD application when using AutoRegister. **Required**.
 
.PARAMETER CertOutPath
    Folder path for the generated .cer/.pfx files in AutoRegister mode. Default: ".\Certs".
 
.EXAMPLE
    PS C:\> Export-PnpAlertsAlerts
    # Launches the interactive wizard (preferred).
 
.EXAMPLE
    PS C:\> Export-PnpAlertsAlerts `
      -AdminCenterUrl https://contoso-admin.sharepoint.com `
      -OutputCsvPath C:\alerts.csv `
      -AutoRegister `
      -AppName "AlertsExportApp"
    # Auto‑registers a new Entra AD app and scans all sites.
 
.EXAMPLE
    PS C:\> Export-PnpAlertsAlerts -AdminCenterUrl https://contoso-admin.sharepoint.com `
      -OutputCsvPath C:\alerts.csv `
      -AppId 12345678-90ab-cdef-1234-567890abcdef `
      -Tenant contoso.onmicrosoft.com `
      -CertificatePath C:\Certs\mycert.pfx `
      -CertificatePassword (ConvertTo-SecureString "P@ssw0rd" -AsPlainText -Force)
    # Uses an existing app to scan all sites (non‑parallel).
 
.EXAMPLE
    PS C:\> Export-PnpAlertsAlerts -AdminCenterUrl https://contoso-admin.sharepoint.com `
      -OutputCsvPath C:\alerts.csv `
      -Mode SingleSite `
      -SiteUrl https://contoso.sharepoint.com/sites/Marketing `
      -AppId 12345678-90ab-cdef-1234-567890abcdef `
      -Tenant contoso.onmicrosoft.com `
      -CertificatePath C:\Certs\mycert.pfx `
      -CertificatePassword (ConvertTo-SecureString "P@ssw0rd" -AsPlainText -Force) `
      -ListMode SpecificLibrary `
      -ListTitle "Shared Documents" `
      -UseParallel
    # Scans a single site’s "Shared Documents" library in parallel using an existing app.
 
.NOTES
    Author: Sam Larson
    Last Updated: April 20, 2025
#>


function Export-Alerts {
    [CmdletBinding()]
    param(
        [string]     $AdminCenterUrl,
        [string]     $OutputCsvPath,
        [ValidateSet('AllSites','SingleSite')]
        [string]     $Mode             = 'AllSites',
        [string]     $SiteUrl,
        [ValidateSet('AllLibraries','SpecificLibrary')]
        [string]     $ListMode         = 'AllLibraries',
        [string]     $ListTitle,
        [switch]     $UseParallel,
        [switch]     $NoSubWebs,
        [string]     $AppId,
        [string]     $Tenant,
        [string]     $CertificatePath,
        [SecureString]$CertificatePassword,
        [switch]     $AutoRegister,
        [string]     $AppName,
        [string]     $CertOutPath      = '.\Certs'
    )

    # ──────────────────────────────────────────────────
    # 🎛️ Interactive wizard: operation, auth, scope, library & parallel
    # ──────────────────────────────────────────────────
    Write-Host "Select an operation:" -ForegroundColor Cyan
    Write-Host " 1) AllSites + Auto‑register new app"
    Write-Host " 2) AllSites + Use existing app"
    Write-Host " 3) SingleSite + Auto‑register new app"
    Write-Host " 4) SingleSite + Use existing app"
    $choice = Read-Host "Enter 1, 2, 3 or 4"
    switch ($choice) {
        '1' { $Mode='AllSites';   $AutoRegister=$true  }
        '2' { $Mode='AllSites';   $AutoRegister=$false }
        '3' { $Mode='SingleSite'; $AutoRegister=$true  }
        '4' { $Mode='SingleSite'; $AutoRegister=$false }
        default { Throw "Invalid choice: $choice" }
    }

    # 📝 Prompt for core inputs
    if (-not $AdminCenterUrl) {
        $AdminCenterUrl = (Read-Host "Enter Admin Center URL (e.g. https://contoso-admin.sharepoint.com)").Trim()
    }
    if (-not $OutputCsvPath) {
        $OutputCsvPath = (Read-Host "Enter path to save CSV (e.g. C:\temp\alerts.csv)").Trim()
    }

    # 🔐 Auth details
    if ($AutoRegister) {
        if (-not $Tenant) {
            $Tenant = (Read-Host "Enter your tenant (e.g. contoso.onmicrosoft.com)").Trim()
        }
        if (-not $AppName) {
            $AppName = (Read-Host "Enter display name for the new Entra AD application").Trim()
        }
    } else {
        if (-not $AppId) {
            $AppId = (Read-Host "Enter your existing App ID (GUID)").Trim()
        }
        if (-not $Tenant) {
            $Tenant = (Read-Host "Enter your tenant (e.g. contoso.onmicrosoft.com)").Trim()
        }
        if (-not $CertificatePath) {
            $CertificatePath = (Read-Host "Enter path to PFX certificate (e.g. C:\Windows\Documents\Certs\ExportAlerts.pfx)").Trim()
        }
        if (-not $CertificatePassword) {
            $CertificatePassword = Read-Host "Enter PFX password (leave blank if none)" -AsSecureString
        }
    }

    # 🌐 Site scope for SingleSite mode
    if ($Mode -eq 'SingleSite') {
        if (-not $SiteUrl) {
            $SiteUrl = (Read-Host "Enter the Site URL (e.g. https://contoso.sharepoint.com/sites/Marketing)").Trim()
        }
    }

    # 📚 Library mode
    Write-Host "Select library mode:" -ForegroundColor Cyan
    Write-Host " 1) All libraries"
    Write-Host " 2) One specific library"
    $libChoice = Read-Host "Enter 1 or 2"
    switch ($libChoice) {
        '1' { $ListMode='AllLibraries';   $ListTitle=$null }
        '2' { 
            $ListMode='SpecificLibrary'
            do { $ListTitle = (Read-Host "Enter the EXACT library title (case‑sensitive)").Trim() } 
            while ([string]::IsNullOrWhiteSpace($ListTitle))
        }
        default { Throw "Invalid library choice: $libChoice" }
    }

    # ⚡️ Parallel mode?
    Write-Host "Enable parallel processing for large tenants? (y/n)" -ForegroundColor Cyan
    $p = Read-Host "Parallel mode"
    $UseParallel = $p -match '^[Yy]'

    # PowerShell 7+ check
    if ($PSVersionTable.PSVersion.Major -lt 7 -or $PSISE) {
        Write-Host "❌ Requires PowerShell 7+ (no ISE)." -ForegroundColor Red
        return
    }

    # Ensure PnP.PowerShell 3.1.0
    Write-Host '🔍 Checking for PnP.PowerShell v3.1.0…' -ForegroundColor Cyan
    if (-not (Get-Module -ListAvailable -Name PnP.PowerShell | Where-Object Version -eq '3.1.0')) {
        Write-Host 'Installing PnP.PowerShell v3.1.0…' -ForegroundColor Yellow
        Install-Module PnP.PowerShell -RequiredVersion 3.1.0 -Scope CurrentUser -Force
    }
    Import-Module PnP.PowerShell -RequiredVersion 3.1.0 -ErrorAction Stop

    # Initialize logging helper
    function Log-Message { param($m,$l='INFO') Write-Host "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [$l] $m" }

 # AutoRegister flow
#
if ($AutoRegister) {
    # 2.1 Consent prompt
    if (-not $Tenant) { throw '-Tenant is required for -AutoRegister.' }
    $consent = Read-Host -Prompt "AutoRegister will create a new Entra app in your tenant. Proceed? (Y/N)"
    if ($consent -notin 'Y','y') {
        Write-Warning "App registration skipped by user."
        break
    }

    # 2.2 Prepare output folder
    Write-Host '🔧 Launching Entra app registration in a new window…' -ForegroundColor Cyan
    $outPath = (Resolve-Path $CertOutPath -ErrorAction SilentlyContinue).ProviderPath   
    if (-not $outPath) {
        $outPath = Join-Path $PWD $CertOutPath
        if (-not (Test-Path $outPath)) { New-Item -ItemType Directory -Path $outPath | Out-Null }
    }

    # 2.3 Ensure PnP.PowerShell v2.4.0 is installed for helper window
    if (-not (Get-Module -ListAvailable -Name 'PnP.PowerShell' |
              Where-Object { $_.Version -eq [Version]'2.4.0' })) {
        Write-Host '🔄 Installing PnP.PowerShell v2.4.0 for helper window...' -ForegroundColor Cyan
        Install-Module -Name PnP.PowerShell -RequiredVersion 2.4.0 -Scope CurrentUser -Force
    }

    # 2.4 Build and launch the one‑liner registration command
    $regCmd = "Import-Module PnP.PowerShell -RequiredVersion 2.4.0 -Force; Register-PnPEntraIDApp -ApplicationName `"$AppName`" -Tenant `"$Tenant`" -DeviceLogin -SharePointApplicationPermissions @('Sites.FullControl.All') -OutPath `"$outPath`" -Verbose | Out-Host"
    Start-Process pwsh -ArgumentList @(
        '-ExecutionPolicy','Bypass',
        '-NoExit',
        '-Command', $regCmd
    )

    # 2.5 Wait for the user to finish in the helper window
    Read-Host -Prompt "✔️ Helper window launched. Complete app registration there, then press Enter to continue in this window"

    # 2.6 Gather the newly created values
    $AppId           = Read-Host "Enter the newly registered ClientId"
    $CertificatePath = Read-Host "Enter the path to the .pfx file"
    if (-not (Test-Path $CertificatePath)) { throw "Cannot find .pfx at $CertificatePath" }

    Write-Host "ℹ️ Optionally check to ensure you have granted consent in the Entra portal: go to https://entra.microsoft.com, search for '$AppName', select API permissions, and verify the required scopes show a green check mark." -ForegroundColor Yellow
    Read-Host "Press Enter to continue…"

    # Newly created certs come without a password
    $CertificatePassword = $null
}

    ############################################################################
    # Authenticate to the SharePoint Admin Center #
    ############################################################################
    Write-Host '🔒 Connecting to SharePoint Admin Center…' -ForegroundColor Cyan
    try {
        $connection = Connect-PnPOnline `
            -Url                  $AdminCenterUrl `
            -ClientId             $AppId `
            -Tenant               $Tenant `
            -CertificatePath      $CertificatePath `
            -CertificatePassword  $CertificatePassword `
            -ReturnConnection `
            -ErrorAction Stop
        Log-Message "Connected to $AdminCenterUrl"
    }
    catch {
        $msg = $_.Exception.Message
        if ($msg -match 'AADSTS700027') {
            Write-Host "⚠️ Certificate not found on your app registration." -ForegroundColor Yellow
            Write-Host " • In Azure portal, navigate to Azure AD → App registrations → '$AppName' → Certificates & secrets" -ForegroundColor Yellow
            Write-Host " • Upload the public (.cer) part of your certificate so MSAL can validate the assertion" -ForegroundColor Yellow
            Write-Host " • See https://aka.ms/msal-net-invalid-client for more info" -ForegroundColor Yellow
        }
        Write-Error "Failed to connect: $_"
        return
    }

    # Track runtime and memory
    $start     = Get-Date
    $initialMB = [math]::Round(([GC]::GetTotalMemory($false)/1MB),2)

    ############################################################################
    # Helper: retryable PnP connection #
    ############################################################################
    function Test-PnPConnection {
        param($Conn,$Url)
        try {
            Get-PnPWeb -Connection $Conn -ErrorAction Stop | Out-Null
            return $Conn
        } catch {
            for ($i = 1; $i -le 3; $i++) {
                Start-Sleep -Seconds (5 * $i)
                try {
                    return Connect-PnPOnline `
                        -Url $Url `
                        -ClientId $AppId `
                        -Tenant $Tenant `
                        -CertificatePath $CertificatePath `
                        -CertificatePassword $CertificatePassword `
                        -ReturnConnection -ErrorAction Stop
                } catch {}
            }
            throw "Cannot reconnect to $Url"
        }
    }

    ############################################################################
    # Function: Get-WebAlerts — fetches alerts from one site #
    ############################################################################
    function Get-WebAlerts {
        param($Url)
        $alerts = @()

        # Connect with backoff
        for ($r = 0; $r -lt 3; $r++) {
            try {
                $conn = Connect-PnPOnline `
                    -Url $Url `
                    -ClientId $AppId `
                    -Tenant $Tenant `
                    -CertificatePath $CertificatePath `
                    -CertificatePassword $CertificatePassword `
                    -ReturnConnection -ErrorAction Stop
                break
            } catch {
                Start-Sleep -Seconds (5 * ($r + 1))
                if ($r -eq 2) { throw "Cannot connect to $Url" }
            }
        }

        # Validate or re‑connect if needed
        $conn = Test-PnPConnection -Conn $conn -Url $Url

        # Retrieve lists per ListMode
        if ($ListMode -eq 'SpecificLibrary' -and $ListTitle) {
            $lists = @(Get-PnPList -Identity $ListTitle -Connection $conn -ErrorAction Stop)
        } else {
            $lists = Get-PnPList -Connection $conn -ErrorAction Stop |
                     Where-Object {
                         $_.BaseTemplate -eq 101 -and
                         -not $_.Hidden -and
                         -not $_.IsCatalog -and
                         -not $_.IsSiteAssetsLibrary
                     }
        }

        # Gather alerts
        foreach ($l in $lists) {
            $la = Get-PnPAlert -List $l.Title -AllUsers -Connection $conn -ErrorAction SilentlyContinue
            foreach ($a in $la) {
                $u = Get-PnPProperty -ClientObject $a -Property User -Connection $conn
                $alerts += [pscustomobject]@{
                    SiteUrl    = $Url
                    AlertTitle = $a.Title
                    User       = $u.LoginName
                    Frequency  = $a.AlertFrequency
                    EventType  = $a.EventType
                    ListTitle  = $l.Title
                    ListUrl    = $l.DefaultViewUrl
                }
            }
        }

        return $alerts
    }

    ############################################################################
    # Fetch site collections or a single site #
    ############################################################################
    if ($Mode -eq 'AllSites') {
        $sites = Get-PnPTenantSite -IncludeOneDriveSites:$false -Connection $connection -ErrorAction Stop
    } else {
        $sites = @([pscustomobject]@{ Url = $SiteUrl })
    }

    ############################################################################
    # Process each site (parallel if requested) – ensure helper functions load
    ############################################################################
    # capture module folder
    $scriptRoot = $PSScriptRoot

    # helper for parallel mode (needs using:)
    $parallelBlock = {
        param($site)
         # bind the pipeline input into $site
            $site = $_
        # load helper functions (Log-Message, Get-WebAlerts, etc.)
        . "$using:scriptRoot\GetWebAlertsFunctions.ps1"

        # authenticate inside this runspace
        $conn = Connect-PnPOnline `
            -Url                 $site.Url `
            -ClientId            $using:AppId `
            -Tenant              $using:Tenant `
            -CertificatePath     $using:CertificatePath `
            -CertificatePassword $using:CertificatePassword `
            -ReturnConnection

        Log-Message "Processing $($site.Url)"

        # root‑site alerts
        $out = Get-WebAlerts `
            -Url                 $site.Url `
            -AppId               $using:AppId `
            -Tenant              $using:Tenant `
            -CertificatePath     $using:CertificatePath `
            -CertificatePassword $using:CertificatePassword `
            -ListMode            $using:ListMode `
            -ListTitle           $using:ListTitle

        # subsites if requested
        if (-not $using:NoSubWebs) {
            $subsites = Get-PnPSubWeb -Recurse -Connection $conn
            foreach ($w in $subsites) {
                $out += Get-WebAlerts `
                    -Url                 $w.Url `
                    -AppId               $using:AppId `
                    -Tenant              $using:Tenant `
                    -CertificatePath     $using:CertificatePath `
                    -CertificatePassword $using:CertificatePassword `
                    -ListMode            $using:ListMode `
                    -ListTitle           $using:ListTitle
            }
        }
        return $out
    }

    # helper for non‑parallel mode (no using:)
    $localBlock = {
        param($site)
        . "$scriptRoot\GetWebAlertsFunctions.ps1"

        $conn = Connect-PnPOnline `
            -Url                 $site.Url `
            -ClientId            $AppId `
            -Tenant              $Tenant `
            -CertificatePath     $CertificatePath `
            -CertificatePassword $CertificatePassword `
            -ReturnConnection

        Log-Message "Processing $($site.Url)"

        $out = Get-WebAlerts `
            -Url                 $site.Url `
            -AppId               $AppId `
            -Tenant              $Tenant `
            -CertificatePath     $CertificatePath `
            -CertificatePassword $CertificatePassword `
            -ListMode            $ListMode `
            -ListTitle           $ListTitle

        if (-not $NoSubWebs) {
            $subsites = Get-PnPSubWeb -Recurse -Connection $conn
            foreach ($w in $subsites) {
                $out += Get-WebAlerts `
                    -Url                 $w.Url `
                    -AppId               $AppId `
                    -Tenant              $Tenant `
                    -CertificatePath     $CertificatePath `
                    -CertificatePassword $CertificatePassword `
                    -ListMode            $ListMode `
                    -ListTitle           $ListTitle
            }
        }
        return $out
    }

    if ($UseParallel) {
        $allAlerts = $sites | ForEach-Object -Parallel $parallelBlock -ThrottleLimit 8
    } else {
        $allAlerts = foreach ($s in $sites) { & $localBlock $s }
    }

    ############################################################################
    # Export results to CSV #
    ############################################################################
    try {
        $count = $allAlerts.Count
        $allAlerts | Export-Csv -Path $OutputCsvPath -NoTypeInformation -Encoding UTF8
        Write-Host "✅ Exported $count alerts → $OutputCsvPath" -ForegroundColor Green
    } catch {
        Write-Error "Export failed: $_"
    }

    # Final stats
    $end   = Get-Date
    $endMB = [math]::Round(([GC]::GetTotalMemory($false)/1MB),2)
    Log-Message "Start: $start | End: $end | Runtime: $($end - $start) | Memory: ${initialMB} → ${endMB}MB"
}

Export-ModuleMember -Function Export-Alerts