Public/Start-NSPEntraUserPairing.ps1

function Start-NSPEntraUserPairing {
    <#
    .SYNOPSIS
        Walks an operator through a queue, one cloud user at a time. Changes nothing in a directory.
    .DESCRIPTION
        Shows each undecided, needs-review or deferred pair, with the options to choose a candidate,
        search AD (with -Server), skip, flag or defer. A selection is recorded immediately, without another confirmation. The queue is saved
        after every decision, so stopping loses nothing. Q stops; run it again to carry on. Diagnostic
        pairs such as already-linked accounts are not offered.
 
        A loaded queue has to go through Sync-NSPEntraPairingQueue first. -WhatIf walks the review
        without saving.
 
        Returns the saved path and the count of each decision.
    .EXAMPLE
        Start-NSPEntraUserPairing -Queue $queue -Server 'dc01.contoso.example' -Forest
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]$Queue,
        [string]$Path,
        [string]$Server,
        [switch]$Forest,
        [switch]$IncludeDecided
    )

    if ($Queue.needsRevalidation) { throw 'This queue was loaded and has not been revalidated. Run Sync-NSPEntraPairingQueue first.' }
    Import-NSPToolkitModule -Name 'NSP.Console' -MinimumVersion '0.1.1'

    if (-not $Path -and $PSCmdlet.ShouldProcess('review queue', 'Save')) { $Path = Export-NSPEntraPairingQueue -Queue $Queue }
    $save = {
        if ($PSCmdlet.ShouldProcess('review queue', 'Save')) { Export-NSPEntraPairingQueue -Queue $Queue -Path $Path | Out-Null }
    }

    $selectable = @('Suggested', 'Ambiguous', 'OneToMany', 'NotFound')
    $rank = @{ 'needs-review' = 0; undecided = 1; deferred = 2 }
    # Sort-Object is not stable under 5.1, so the UPN keeps the order the same on both hosts.
    $work = @(@($Queue.pairs) | Where-Object {
            $_.status -in $selectable -and ($IncludeDecided -or $_.decision -in @('undecided', 'needs-review', 'deferred'))
        } | Sort-Object @{ Expression = { if ($rank.ContainsKey([string]$_.decision)) { $rank[[string]$_.decision] } else { 3 } } }, cloudUpnAtReview)

    $position = 0
    $finalReview = $false
    $completed = $false
    :review while ($true) {
        if ($finalReview -or $position -ge $work.Count) {
            $all = @($Queue.pairs | Where-Object { $_.status -in $selectable -and $_.executionState -eq 'pending' } | Sort-Object cloudUpnAtReview)
            Clear-NSPConsole
            Write-NSPConsoleRule -Title 'Final review - choose any user to change the decision'
            $finalChoices = @($all | ForEach-Object {
                [pscustomobject]@{ Label = "$($_.cloudUpnAtReview) -> $($_.adUserPrincipalNameAtReview) [$($_.decision)] $($_.roleReview)"; Value = "edit:$($_.cloudObjectId)" }
            }) + @([pscustomobject]@{ Key = 'X'; Label = 'Finish and save review'; Value = 'finish' })
            $answer = Read-NSPMenu -Prompt 'Review every decision before finishing. This saves choices only.' -Choices $finalChoices -CancelLabel 'Save and stop for now'
            if ($answer -eq 'finish') { $completed = $true; break review }
            if ($null -eq $answer) { break review }
            $id = ([string]$answer).Substring('edit:'.Length)
            $work = $all
            $position = 0
            while ($position -lt $work.Count -and $work[$position].cloudObjectId -ne $id) { $position++ }
            if ($position -ge $work.Count) { continue }
            $finalReview = $true
        }
        $pair = $work[$position]

        while ($true) {
            Show-NSPEntraPairReview -Pair $pair -Position ($position + 1) -Total $work.Count

            $choices = [System.Collections.Generic.List[object]]::new()
            foreach ($candidate in @($pair.candidates)) {
                $disabled = if ($candidate.enabled) { '' } else { ', DISABLED' }
                $choices.Add([pscustomobject]@{
                        Label = "$($candidate.domain)\$($candidate.samAccountName) $($candidate.userPrincipalName) [$($candidate.confidence)$disabled]"
                        Value = "select:$($candidate.adObjectGuid)"
                        Help = @($candidate.evidence) -join ', '
                    })
            }
            if ($Server) { $choices.Add([pscustomobject]@{ Key = 'S'; Label = 'Search AD'; Value = 'search' }) }
            if ($position -gt 0) { $choices.Add([pscustomobject]@{ Key = 'B'; Label = 'Back to previous user'; Value = 'back' }) }
            $choices.Add([pscustomobject]@{ Key = 'R'; Label = 'Review all decisions'; Value = 'review-all' })
            $choices.Add([pscustomobject]@{ Key = 'K'; Label = 'Skip'; Value = 'skipped' })
            $choices.Add([pscustomobject]@{ Key = 'F'; Label = 'Flag for someone to check'; Value = 'flagged' })
            $choices.Add([pscustomobject]@{ Key = 'D'; Label = 'Defer'; Value = 'deferred' })

            $answer = Read-NSPMenu -Prompt 'What should happen to this user?' -Choices $choices.ToArray() -CancelLabel 'Stop for now'
            if ($null -eq $answer) { break review }

            if ($answer -eq 'back') { if ($position -gt 0) { $position-- }; $finalReview = $false; continue review }
            if ($answer -eq 'review-all') { $finalReview = $true; continue review }
            try {
                if ($answer -in @('skipped', 'deferred')) {
                    Add-NSPEntraPairDecision -Queue $Queue -CloudObjectId $pair.cloudObjectId -Decision $answer | Out-Null
                } elseif ($answer -eq 'flagged') {
                    $note = Read-NSPOptional -Prompt 'What should they check?'
                    Add-NSPEntraPairDecision -Queue $Queue -CloudObjectId $pair.cloudObjectId -Decision flagged -Note $note | Out-Null
                } else {
                    $decision = @{ Queue = $Queue; CloudObjectId = $pair.cloudObjectId; Decision = 'selected' }
                    if ($answer -eq 'search') {
                        $term = Read-NSPNonEmpty -Prompt 'Search sAMAccountName, UPN, name, mail or proxy address (* is a wildcard)' -AllowBack
                        if (Test-NSPBackSignal $term) { continue }
                        $found = @(Find-NSPEntraPairADUser -Term $term -Server $Server -Forest:$Forest)
                        if (-not $found.Count) {
                            Write-NSPConsoleLine -Message 'No AD user matched.' -Role Warning
                            continue
                        }
                        $picks = @($found | ForEach-Object { [pscustomobject]@{ Label = "$($_.DomainDNSRoot)\$($_.SamAccountName) $($_.UserPrincipalName) $($_.DisplayName)"; Value = $_ } })
                        $picked = Read-NSPChoice -Choices $picks -Prompt 'Which AD user?'
                        if ($null -eq $picked) { continue }
                        $decision.ADUser = $picked
                    } else {
                        $guid = ([string]$answer).Substring('select:'.Length)
                        $decision.AdObjectGuid = $guid
                    }
                    Add-NSPEntraPairDecision @decision | Out-Null
                }
            } catch [System.Management.Automation.RuntimeException] {
                # A refused selection, such as an AD user already taken. The operator chooses again.
                Write-NSPConsoleLine -Message $_.Exception.Message -Role Error
                continue
            }
            & $save
            $position++
            continue review
        }
    }
    & $save

    $counts = [ordered]@{}
    foreach ($group in @(@($Queue.pairs) | Group-Object decision)) { $counts[$group.Name] = $group.Count }
    [pscustomobject]@{ Path = $Path; Decisions = [pscustomobject]$counts; ReviewCompleted = $completed }
}