Private/Invoke-AccessPackageAssignmentRequest.ps1

function Invoke-AccessPackageAssignmentRequest {
    <#
    .SYNOPSIS
        Engine behind Add-SiSAccessPackageAssignment and
        Remove-SiSAccessPackageAssignment: resolves the target users, previews,
        confirms once and submits adminAdd/adminRemove requests in batches.
    .NOTES
        Internal helper - call Add-SiSAccessPackageAssignment or
        Remove-SiSAccessPackageAssignment instead. Exactly one of -AdminAdd /
        -AdminRemove must be given.
    #>


    [OutputType('SiSGovernance.AssignmentResult')]
    [CmdletBinding(DefaultParameterSetName = 'Users', SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param(
        [Parameter(Mandatory = $true)]
        [string]$AccessPackageId,

        [Parameter(Mandatory = $true)]
        [string]$AssignmentPolicyId,

        [Parameter(Mandatory = $true, ParameterSetName = 'Excel')]
        [string]$ExcelPath,

        [Parameter(Mandatory = $true, ParameterSetName = 'Filter')]
        [string]$Filter,

        [Parameter(Mandatory = $true, ParameterSetName = 'Users', ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]$UserPrincipalName,

        [Parameter(Mandatory = $false)]
        [switch]$AdminAdd,

        [Parameter(Mandatory = $false)]
        [switch]$AdminRemove,

        [Parameter(Mandatory = $false)]
        [int]$MaxUsers = 500,

        [Parameter(Mandatory = $false)]
        [switch]$SkipReport,

        [Parameter(Mandatory = $false)]
        [string]$OutputPath,

        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 20)]
        [int]$BatchSize = 20,

        [Parameter(Mandatory = $false)]
        [ValidateRange(0, 60000)]
        [int]$DelayMs = 1000
    )

    begin {
        # Checked once, before anything is collected from the pipeline
        $abort = $false
        if ($AdminAdd.IsPresent -eq $AdminRemove.IsPresent) {
            Write-Error -Message "Specify exactly one of -AdminAdd or -AdminRemove." -Category InvalidArgument
            $abort = $true
        }
        $collectedUpns = [System.Collections.Generic.List[string]]::new()
    }

    process {
        # Pipeline input arrives one item at a time - collect everything first,
        # so the preview, -MaxUsers, the confirmation and the batching happen once
        if ($abort -or $PSCmdlet.ParameterSetName -ne 'Users') { return }
        foreach ($upn in $UserPrincipalName) {
            if ("$upn".Trim()) { $collectedUpns.Add("$upn".Trim()) }
        }
    }

    end {
        if ($abort) { return }
        if ($PSCmdlet.ParameterSetName -eq 'Users') {
            # Same user piped in twice (e.g. member of two lists) -> one request
            $UserPrincipalName = @($collectedUpns | Sort-Object -Unique)
            if ($UserPrincipalName.Count -eq 0) {
                Write-Warning "No users received. Nothing to do."
                return
            }
        }

        $requestType = if ($AdminAdd) { 'adminAdd' } else { 'adminRemove' }
        $operation = if ($AdminAdd) { 'ADD' } else { 'REMOVE' }

        Write-Host "==========================================" -ForegroundColor DarkGray
        Write-Host " ACCESS PACKAGE ASSIGNMENT ($operation) " -ForegroundColor Cyan
        Write-Host "==========================================" -ForegroundColor DarkGray

        # --- OUTPUT FOLDER (first, so the run never stops halfway to ask) ---
        $folderPath = $null
        if (-not $SkipReport) {
            $folderPath = Resolve-OutputFolder -OutputPath $OutputPath
            if (-not $folderPath) { return }
        }

        if (-not (Confirm-GraphConnection)) { return }

        $context = Resolve-AccessPackageContext -AccessPackageId $AccessPackageId -AssignmentPolicyId $AssignmentPolicyId
        if (-not $context) { return }

        $resolution = Resolve-AccessPackageTargetUsers -Source $PSCmdlet.ParameterSetName `
            -ExcelPath $ExcelPath -Filter $Filter -UserPrincipalName $UserPrincipalName
        if (-not $resolution -or $resolution.TargetUsers.Count -eq 0) {
            Write-Warning "No users to process. Nothing to do."
            return
        }
        $targetUsers = $resolution.TargetUsers

        if ($targetUsers.Count -gt $MaxUsers) {
            Write-Error -Message "This run would touch $($targetUsers.Count) users, which exceeds -MaxUsers ($MaxUsers). Narrow the input, or re-run with a higher -MaxUsers if this is intentional." -Category LimitsExceeded
            return
        }

        Write-Host "`n Preview of target users (first 10 of $($targetUsers.Count)):" -ForegroundColor Cyan
        $targetUsers | Select-Object -First 10 -Property displayName, userPrincipalName | Format-Table -AutoSize | Out-Host

        Write-Host "`n $operation $($targetUsers.Count) user(s):" -ForegroundColor Yellow
        Write-Host " Package : $($context.AccessPackageName)" -ForegroundColor White
        Write-Host " Policy : $($context.AssignmentPolicyName)" -ForegroundColor White

        # One confirmation for the whole run, also when many users are piped in
        $action = if ($AdminAdd) { 'Add assignment' } else { 'Remove assignment' }
        $target = "$($targetUsers.Count) user(s) on access package '$($context.AccessPackageName)'"
        if (-not $PSCmdlet.ShouldProcess($target, $action)) {
            if ($WhatIfPreference) {
                $previewStatus = if ($AdminAdd) { 'Preview - Add' } else { 'Preview - Remove' }
                foreach ($u in $targetUsers) {
                    [PSCustomObject]@{
                        PSTypeName        = 'SiSGovernance.AssignmentResult'
                        UserPrincipalName = $u.userPrincipalName
                        DisplayName       = $u.displayName
                        ObjectId          = $u.id
                        Status            = $previewStatus
                        Error             = $null
                    }
                }
            }
            Write-Warning "Cancelled - no changes were made."
            return
        }

        $results = Invoke-AccessPackageBatchOperation `
            -AccessPackageId $AccessPackageId `
            -AssignmentPolicyId $AssignmentPolicyId `
            -UserList $targetUsers `
            -RequestType $requestType `
            -BatchSize $BatchSize `
            -DelayMs $DelayMs

        foreach ($f in $resolution.NotFound) {
            $results += [PSCustomObject]@{
                UserPrincipalName = $f.userPrincipalName
                DisplayName       = $null
                ObjectId          = $null
                Status            = 'Failed - User not found'
                Error             = $f.Error
            }
        }

        Write-Host "`n Results:" -ForegroundColor Cyan

        $results | Format-Table | Out-String -Width 4096 | Write-Host

        if ($SkipReport) {
            Write-Host " -SkipReport specified: results not exported." -ForegroundColor Yellow
        }
        elseif (@($results).Count -gt 0) {
            # Timestamp to the second, so a second run the same day never overwrites the first report
            $stamp = Get-Date -Format 'yyyy-MM-dd_HHmmss'
            $safeName = $context.AccessPackageName -replace '[^\w\-]', '_'
            $exportPath = Join-Path $folderPath "$operation-$safeName-$stamp.xlsx"

            $excel = Export-SafeExcelTable -InputObject @($results) -Path $exportPath -WorksheetName 'Results' -TableName 'Results'
            Close-ExcelPackage $excel

            Write-Host " Results exported to: $exportPath" -ForegroundColor Green
        }

        $summary = $results | Group-Object Status | Sort-Object Count -Descending
        Write-Host "`n==========================================" -ForegroundColor DarkGray
        Write-Host " OPERATION COMPLETE" -ForegroundColor Cyan
        Write-Host "==========================================" -ForegroundColor DarkGray
        foreach ($s in $summary) {

            $statusLabel = if ($s.Name -eq 'Submitted') { 'Success' } else { $s.Name }
            Write-Host " ${statusLabel}: $($s.Count)" -ForegroundColor White
            if ($s.Name -like 'Failed*') {

                $errorGroups = $s.Group | Group-Object Error | Sort-Object Count -Descending
                foreach ($eg in $errorGroups) {
                    $errorLabel = if ($eg.Name) { $eg.Name } else { '(no error message)' }
                    Write-Host " - $($errorLabel): $($eg.Count)" -ForegroundColor DarkYellow
                }
            }
        }

        # One object per user, so the results can be piped on (Where-Object, Export-Excel...)
        foreach ($r in $results) {
            $r.PSObject.TypeNames.Insert(0, 'SiSGovernance.AssignmentResult')
            $r
        }
    }
}