Public/Get-M365UsageReport.ps1

function Get-M365UsageReport {
    <#
    .SYNOPSIS
        Retrieves a usage report from Microsoft Graph.
    .DESCRIPTION
        Generic cmdlet driven by -ReportName. Report definitions live in Data/ReportDefinitions.psd1.
        Handles CSV download, BOM stripping, header deduplication, column sanitization.
    .OUTPUTS
        [PSCustomObject[]] One object per row in the report.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$AccessToken,

        [Parameter(Mandatory)]
        [string]$ReportName,

        [ValidateSet('D7', 'D30', 'D90', 'D180')]
        [string]$Period = 'D30'
    )

    # Load report definitions
    $defsPath = Join-Path $PSScriptRoot '..\Data\ReportDefinitions.psd1'
    $reportDefs = Import-PowerShellDataFile -Path $defsPath

    if (-not $reportDefs.ContainsKey($ReportName)) {
        $validNames = ($reportDefs.Keys | Sort-Object) -join ', '
        throw "Unknown ReportName '$ReportName'. Valid names: $validNames"
    }

    $def = $reportDefs[$ReportName]
    $uri = "https://graph.microsoft.com/$($def.ApiVersion)/$($def.Endpoint -f $Period)"

    Write-M365Log "Fetching $($def.DisplayName)..."

    $headers = @{ 'Authorization' = "Bearer $AccessToken" }
    $response = Invoke-WebRequest -Uri $uri -Headers $headers -Method Get

    # Decode response
    if ($response.Content -is [byte[]]) {
        $csvContent = [System.Text.Encoding]::UTF8.GetString($response.Content)
    }
    else {
        $csvContent = $response.Content
    }

    # Strip BOM and normalize line endings
    if ($csvContent.Length -gt 0 -and $csvContent[0] -eq [char]0xFEFF) {
        $csvContent = $csvContent.Substring(1)
    }
    $csvContent = $csvContent -replace "`r`n", "`n" -replace "`r", "`n"

    # Deduplicate headers (beta APIs may return duplicate column names)
    $csvLines = $csvContent -split "`n", 2
    if ($csvLines.Count -lt 2) {
        Write-M365Log " No data returned for $($def.DisplayName)" -Level Warning
        return
    }

    $rawHeaders = $csvLines[0] -split ','
    $seen = [System.Collections.Generic.Dictionary[string,int]]::new([System.StringComparer]::OrdinalIgnoreCase)
    $dedupedHeaders = @()
    foreach ($h in $rawHeaders) {
        $cleanHeader = ($h -replace '"', '').Trim().TrimEnd(':').Trim()
        if ($seen.ContainsKey($cleanHeader)) {
            $seen[$cleanHeader]++
            $dedupedHeaders += "${cleanHeader}_$($seen[$cleanHeader])"
        }
        else {
            $seen[$cleanHeader] = 1
            $dedupedHeaders += $cleanHeader
        }
    }
    $csvContent = ($dedupedHeaders -join ',') + "`n" + $csvLines[1]

    $records = $csvContent | ConvertFrom-Csv

    if (-not $records -or $records.Count -eq 0) {
        Write-M365Log " No data returned for $($def.DisplayName)" -Level Warning
        return
    }

    Write-M365Log " Retrieved $($records.Count) records"

    # Sanitize column names to UpperCamelCase and add metadata
    $ingestionTime = (Get-Date).ToUniversalTime().ToString('o')
    $periodValue = [int]($Period -replace '\D', '')

    foreach ($record in $records) {
        $sanitizedRecord = [PSCustomObject]@{}
        foreach ($prop in $record.PSObject.Properties) {
            $sanitizedName = $prop.Name -replace '\s*\(', '' -replace '\)', '' -replace '/', '' -replace '-', '' -replace ':', '' -replace '"', ''
            $words = $sanitizedName.Trim() -split '\s+'
            $sanitizedName = ($words | ForEach-Object {
                if ($_.Length -gt 0) {
                    $_.Substring(0, 1).ToUpper() + $_.Substring(1)
                }
            }) -join ''
            $sanitizedRecord | Add-Member -NotePropertyName $sanitizedName -NotePropertyValue $prop.Value -Force
        }
        $sanitizedRecord | Add-Member -NotePropertyName 'IngestionTime' -NotePropertyValue $ingestionTime -Force
        $sanitizedRecord | Add-Member -NotePropertyName 'IngestionReportPeriod' -NotePropertyValue $periodValue -Force
        $sanitizedRecord
    }

    Write-M365Log "Emitted $($def.DisplayName) records to pipeline"
}