Public/Scan/Get-SPCStorageWaste.ps1

function Get-SPCStorageWaste {
    <#
    .SYNOPSIS
        Scans SharePoint Online sites to quantify storage waste and identify cost optimization opportunities.
 
    .DESCRIPTION
        Get-SPCStorageWaste analyzes SharePoint Online site collections to detect and measure storage consumption
        across critical waste vectors: first-stage recycle bin, second-stage recycle bin, historical document
        version sprawl, and Preservation Hold Libraries (PHL).
 
        The cmdlet calculates financial waste based on the standard Microsoft 365 SharePoint extra storage cost
        model ($0.20/GB/month or $2,400/TB/year), enabling IT Administrators and Directors to quantify immediate
        cost avoidance and reclaim storage capacity before reaching tenant quota limits.
 
    .PARAMETER SiteUrl
        One or more SharePoint Online site collection URLs to scan. When omitted, all sites in the tenant
        are enumerated and evaluated using Microsoft Graph usage reports.
        Supports pipeline input by value and property name.
 
    .PARAMETER IncludeRecycleBin
        Specifies whether to inspect first-stage (End-User) and second-stage (Site Collection Administrator)
        recycle bins. Enabled by default ($true).
 
    .PARAMETER IncludeVersions
        When specified, performs an in-depth version sprawl analysis across document libraries to calculate
        prunable version storage (simulating a 50-version, 90-day retention threshold).
 
    .PARAMETER IncludePreservationHold
        When specified, audits the Preservation Hold Library (PHL) created by Microsoft Purview retention
        and eDiscovery holds to measure locked storage consumption.
 
    .PARAMETER Top
        Limits the output to the top N sites with the highest total recoverable waste (in MB). Default is 0 (all sites).
 
    .INPUTS
        System.String[]
            Accepts site collection URLs from the pipeline.
 
    .OUTPUTS
        SPC.StorageWasteSummary
            Returns custom objects containing site storage metrics, recycle bin breakdowns, version waste,
            preservation hold usage, total waste in MB, and potential monthly/annual cost savings in USD.
 
    .EXAMPLE
        Get-SPCStorageWaste -SiteUrl 'https://contoso.sharepoint.com/sites/Marketing' -IncludeVersions -IncludePreservationHold
 
        Scans the 'Marketing' site collection, performing a full audit including 1st/2nd stage recycle bins,
        document version sprawl, and Preservation Hold Library sizes.
 
    .EXAMPLE
        Get-SPCStorageWaste -Top 10 -IncludeVersions | Export-SPCStorageReport -Format HTML -OutputPath 'C:\Reports'
 
        Discovers all tenant sites, identifies the top 10 sites with the highest storage waste including version sprawl,
        and generates an interactive executive HTML ROI dashboard.
 
    .EXAMPLE
        $sites = @('https://contoso.sharepoint.com/sites/Legal', 'https://contoso.sharepoint.com/sites/Engineering')
        $sites | Get-SPCStorageWaste -IncludeRecycleBin | Where-Object TotalWasteMB -gt 51200 | ForEach-Object {
            Clear-SPCRecycleBin -SiteUrl $_.SiteUrl -OlderThanDays 30 -DryRun
        }
 
        Pipes specific high-capacity sites into Get-SPCStorageWaste, filters for sites with more than 50 GB of waste,
        and previews safe recycle bin remediation using -DryRun.
 
    .NOTES
        Requires an active SPClean connection initialized via Connect-SPCTenant.
        When scanning all sites without specifying -SiteUrl, Microsoft Graph Site Usage reports are utilized.
 
    .LINK
        Get-SPCVersionWaste
    .LINK
        Get-SPCPreservationHoldWaste
    .LINK
        Get-SPCInactiveSite
    .LINK
        Clear-SPCRecycleBin
    .LINK
        Export-SPCStorageReport
    #>

    [CmdletBinding(DefaultParameterSetName = 'AllSites')]
    [OutputType('SPC.StorageWasteSummary')]
    param(
        [Parameter(ParameterSetName = 'SpecificSites', Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [string[]]$SiteUrl,

        [Parameter()]
        [switch]$IncludeRecycleBin = $true,

        [Parameter()]
        [switch]$IncludeVersions,

        [Parameter()]
        [switch]$IncludePreservationHold,

        [Parameter()]
        [ValidateRange(0, 50000)]
        [int]$Top = 0
    )

    begin {
        Test-SPCConnection
        Write-Verbose "Get-SPCStorageWaste: Initializing discovery scan. IncludeRecycleBin=$IncludeRecycleBin, IncludeVersions=$IncludeVersions, IncludePHL=$IncludePreservationHold"
        $results = [System.Collections.Generic.List[PSCustomObject]]::new()
        $pipelineUrls = [System.Collections.Generic.List[string]]::new()
    }

    process {
        if ($PSCmdlet.ParameterSetName -eq 'SpecificSites') {
            foreach ($url in $SiteUrl) {
                if (-not [string]::IsNullOrWhiteSpace($url)) {
                    $pipelineUrls.Add($url)
                }
            }
        }
    }

    end {
        $targetUrls = if ($PSCmdlet.ParameterSetName -eq 'SpecificSites') {
            $pipelineUrls
        } else {
            $usageReport = Get-SPCGraphSiteUsageInternal -Period 'D7'
            $urls = [System.Collections.Generic.List[string]]::new()
            if ($null -ne $usageReport) {
                foreach ($row in $usageReport) {
                    if (-not [string]::IsNullOrWhiteSpace($row.SiteUrl)) {
                        $urls.Add($row.SiteUrl)
                    }
                }
            }
            $urls
        }

        $totalSites = $targetUrls.Count
        $currentIndex = 0

        foreach ($url in $targetUrls) {
            $currentIndex++
            Write-Progress -Activity "Scanning Storage Waste" -Status ("Processing site {0} of {1}: {2}" -f $currentIndex, $totalSites, $url) -PercentComplete (($currentIndex / [Math]::Max(1, $totalSites)) * 100)

            try {
                $siteConn = Connect-SPCSiteInternal -SiteUrl $url -Context $script:SPCContext
                $web = Get-PnPWeb -Connection $siteConn
                $site = Get-PnPSite -Connection $siteConn
                if ($null -ne $site -and ($null -eq $site.Usage -or $null -eq $site.Usage.Storage)) {
                    Get-PnPProperty -ClientObject $site -Property Usage -Connection $siteConn -ErrorAction SilentlyContinue | Out-Null
                }

                $storageUsedMB = if ($null -ne $site.Usage -and $null -ne $site.Usage.Storage) { [Math]::Round(($site.Usage.Storage / 1MB), 2) } else { 0.0 }
                $storageAllocatedMB = if ($null -ne $site.Usage -and $null -ne $site.Usage.StorageQuota) { [Math]::Round(($site.Usage.StorageQuota / 1MB), 2) } else { 0.0 }
                $storageUsedPercent = if ($storageAllocatedMB -gt 0) { [Math]::Round(($storageUsedMB / $storageAllocatedMB) * 100, 2) } else { 0.0 }

                # 1. Recycle Bin
                $rb1MB = 0.0
                $rb2MB = 0.0
                if ($IncludeRecycleBin) {
                    $allRbItems = Get-PnPRecycleBinItem -Connection $siteConn -RowLimit 5000
                    $rbItems1 = if ($null -ne $allRbItems) { @($allRbItems | Where-Object { $_.ItemState -eq 'FirstStageRecycleBin' }) } else { @() }
                    $rbItems2 = if ($null -ne $allRbItems) { @($allRbItems | Where-Object { $_.ItemState -eq 'SecondStageRecycleBin' }) } else { @() }
                    $rb1MB = [Math]::Round((($rbItems1 | Measure-Object -Property Size -Sum).Sum / 1MB), 2)
                    $rb2MB = [Math]::Round((($rbItems2 | Measure-Object -Property Size -Sum).Sum / 1MB), 2)
                }
                $totalRecycleBinMB = [Math]::Round(($rb1MB + $rb2MB), 2)

                # 2. Version Waste
                $versionWasteMB = 0.0
                if ($IncludeVersions) {
                    $vw = Get-SPCVersionWaste -SiteUrl $url -KeepVersions 50 -OlderThanDays 90 -ErrorAction SilentlyContinue
                    if ($null -ne $vw) {
                        $versionWasteMB = [Math]::Round((($vw | Measure-Object -Property RecoverableStorageMB -Sum).Sum), 2)
                    }
                }

                # 3. Preservation Hold Library
                $phlMB = 0.0
                if ($IncludePreservationHold) {
                    $phl = Get-SPCPreservationHoldWaste -SiteUrl @($url) -ErrorAction SilentlyContinue
                    if ($null -ne $phl) {
                        $phlMB = [Math]::Round((($phl | Measure-Object -Property PHLSizeMB -Sum).Sum), 2)
                    }
                }

                # Note: PreservationHoldMB is excluded from TotalWasteMB because PHL is compliance-locked
                # by Microsoft Purview policies and cannot be purged via standard remediation cmdlets.
                $totalWasteMB = [Math]::Round(($totalRecycleBinMB + $versionWasteMB), 2)
                $potentialMonthlyUSD = [Math]::Round((($totalWasteMB / 1024) * 0.20), 2)
                $potentialAnnualUSD  = [Math]::Round(($potentialMonthlyUSD * 12), 2)

                $siteTitle = if ($null -ne $web -and $null -ne $web.Title) { $web.Title } else { 'Site' }

                $record = [PSCustomObject][ordered]@{
                    PSTypeName                = 'SPC.StorageWasteSummary'
                    SiteUrl                   = $url
                    SiteTitle                 = $siteTitle
                    StorageUsedMB             = $storageUsedMB
                    StorageAllocatedMB        = $storageAllocatedMB
                    StorageUsedPercent        = $storageUsedPercent
                    RecycleBin1stStageMB      = $rb1MB
                    RecycleBin2ndStageMB      = $rb2MB
                    TotalRecycleBinMB         = $totalRecycleBinMB
                    VersionWasteMB            = $versionWasteMB
                    PreservationHoldMB        = $phlMB
                    TotalWasteMB              = $totalWasteMB
                    PotentialMonthlySavingUSD = $potentialMonthlyUSD
                    PotentialAnnualSavingUSD  = $potentialAnnualUSD
                    ScannedAt                 = (Get-Date).ToUniversalTime()
                }
                $results.Add($record)
            }
            catch {
                Write-Warning "Get-SPCStorageWaste: Failed to scan site '$url': $($_.Exception.Message)"
            }
        }

        Write-Progress -Activity "Scanning Storage Waste" -Completed
        $sorted = $results | Sort-Object -Property TotalWasteMB -Descending
        if ($Top -gt 0) {
            $sorted | Select-Object -First $Top
        } else {
            $sorted
        }
    }
}