Public/UserManagement/VoiceConfig/Find-TeamsUserVoiceConfig.ps1

# Module: TeamsFunctions
# Function: VoiceConfig
# Author: David Eberhardt
# Updated: 01-DEC-2020
# Status: Live

#TODO wrap paging parameters in Get-CsOnlineUser query itself to reduce load time? #103


function Find-TeamsUserVoiceConfig {
  <#
  .SYNOPSIS
    Displays User Accounts matching a specific Voice Configuration Parameter
  .DESCRIPTION
    Returns UserPrincipalNames of Objects matching specific parameters. For PhoneNumbers also displays their basic Voice Configuration
    Search parameters are mutually exclusive, only one Parameter can be specified at the same time.
    Available parameters are:
    - PhoneNumber: Part of the LineURI (ideally without 'tel:','+' or ';ext=...')
    - ConfigurationType: 'CallPlans' or 'DirectRouting'. Will deliver partially configured accounts as well.
    - VoicePolicy: 'BusinessVoice' (CallPlans) or 'HybridVoice' (DirectRouting or any other Hybrid PSTN configuration)
    - OnlineVoiceRoutingPolicy: Any string value (incl. $Null), but not empty ones.
    - TenantDialPlan: Any string value (incl. $Null), but not empty ones.
  .PARAMETER UserPrincipalName
    Optional. UserPrincipalName (UPN) of the User
    Behaves like Get-TeamsUserVoiceConfig, displaying the Users Voice Configuration
  .PARAMETER PhoneNumber
    Optional. Searches all Users matching the given String in their LineURI.
    The expected ResultSize is limited, the full Object is displayed (Get-TeamsUserVoiceConfig)
    Please see NOTES for details
  .PARAMETER OnlineVoiceRoutingPolicy
    Optional. Searches all enabled Users which have the OnlineVoiceRoutingPolicy specified assigned.
    Please specify full and correct name or '$null' to receive all Users without one
    The expected ResultSize is big, therefore only UserPrincipalNames are returned
    Please see NOTES for details
  .PARAMETER TenantDialPlan
    Optional. Searches all enabled Users which have the TenantDialPlan specified assigned.
    Please specify full and correct name or '$null' to receive all Users without one
    The expected ResultSize is big, therefore only UserPrincipalNames are returned
    Please see NOTES for details
  .PARAMETER ValidateLicense
    Optional. In addition to validation of Parameters, also validates License assignment for the found user(s).
    This Parameter will initiate a quick check against the PhoneSystem License of each found account and will only return
    objects that are correctly configured
    License Check is performed AFTER parameters are verified.
  .EXAMPLE
    Find-TeamsUserVoiceConfig -UserPrincipalName John@domain.com
 
    Shows Voice Configuration for John, returning the full Object (query with Get-TeamsUserVoiceConfig)
  .EXAMPLE
    Find-TeamsUserVoiceConfig -PhoneNumber "15551234567"
 
    Shows all Users which have this String in their LineURI (TelephoneNumber or LineURI)
    The expected ResultSize is limited, if only one result is shown, the full Object is returned (Get-TeamsUserVoiceConfig)
    Please see NOTES for details
  .EXAMPLE
    Find-TeamsUserVoiceConfig -ConfigurationType DirectRouting
 
    Shows all Users which are configured for DirectRouting
    The expected ResultSize is big
    Please see NOTES for details
  .EXAMPLE
    Find-TeamsUserVoiceConfig -VoicePolicy BusinessVoice
 
    Shows all Users which are configured for PhoneSystem with CallingPlans
    The expected ResultSize is big, therefore only Names (UPNs) of Users are displayed
    Please see NOTES and LINK for details
  .EXAMPLE
    Find-TeamsUserVoiceConfig -OnlineVoiceRoutingPolicy O_VP_EMEA -First 300
 
    Shows all Users which have the OnlineVoiceRoutingPolicy "O_VP_EMEA" assigned
    Depending on the Size of your tenant, the expected ResultSize is big, paging parameters can help reduce output
    Please see NOTES for details
  .EXAMPLE
    Find-TeamsUserVoiceConfig -TenantDialPlan DP-US
 
    Shows all Users which have the TenantDialPlan "DP-US" assigned.
    Please see NOTES for details
  .INPUTS
    System.String
  .OUTPUTS
    System.String - UserPrincipalName - With any Parameter except Identity or PhoneNumber
    System.Object - With Parameter Identity or PhoneNumber
  .NOTES
    All searches are filtering on Get-CsOnlineUser and are supporting paging
    This usually should not take longer than a minute to complete.
    If more than three results are found, only the UserPrincipalNames of the objects are returned
    Otherwise, the object queries the full output through Get-TeamsUserVoiceConfig
 
    Search behaviour:
    - PhoneNumber: Searches against the LineURI parameter. For best compatibility, provide in E.164 format (with or without the +)
    This script can find duplicate assignments if the Number was assigned with and without an extension.
    - Extension: Searches against the LineURI parameter and considers all strings after ";ext=" an extension.
    This script can find duplicate assignments if the Extension was assigned to multiple Numbers.
    - ConfigurationType: Filtering based on Microsofts Documentation for DirectRouting, SkypeForBusiness Hybrid PSTN and CallingPlans
    - OnlineVoiceRoutingPolicy: Finds all users which have this particular Policy assigned
    - TenantDialPlan: Finds all users which have this particular DialPlan assigned.
    Please see Related Link for more information
 
    Output is designed to be piped to Get-TeamsUserVoiceConfiguration for full evaluation of Licenses and configuration.
  .COMPONENT
    VoiceConfiguration
  .FUNCTIONALITY
    Finding Users with a specific values in their Voice Configuration
  .LINK
    https://github.com/DEberhardt/TeamsFunctions/tree/main/docs/Find-TeamsUserVoiceConfig.md
  .LINK
    https://github.com/DEberhardt/TeamsFunctions/tree/main/docs/about_VoiceConfiguration.md
  .LINK
    https://github.com/DEberhardt/TeamsFunctions/tree/main/docs/about_UserManagement.md
  .LINK
    https://github.com/DEberhardt/TeamsFunctions/tree/main/docs/
  .LINK
    https://docs.microsoft.com/en-us/microsoftteams/direct-routing-migrating
  #>


  [CmdletBinding(DefaultParameterSetName = 'Tel', SupportsPaging)]
  [Alias('Find-TeamsUVC')]
  [OutputType([PSCustomObject])]
  param(
    [Parameter(ParameterSetName = 'ID')]
    [Alias('ObjectId', 'Identity')]
    [string]$UserPrincipalName,

    [Parameter(ParameterSetName = 'Tel', Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName, HelpMessage = 'String to be found in any of the PhoneNumber fields')]
    [ValidateScript( {
        If ($_ -match $script:TFMatchNumber) { $True } else {
          throw [System.Management.Automation.ValidationMetadataException] 'Not a valid phone number format. Expected min 4 digits, but multiple formats accepted. Extensions will be stripped'
        } })]
    [Alias('Number', 'TelephoneNumber', 'Tel', 'LineURI')]
    [string]$PhoneNumber,

    [Parameter(ParameterSetName = 'Ext', HelpMessage = 'String to be found in any of the PhoneNumber fields as an Extension')]
    [Alias('Ext')]
    [string]$Extension,

    [Parameter(ParameterSetName = 'OVP', HelpMessage = 'Filters based on OnlineVoiceRoutingPolicy')]
    [AllowNull()]
    [Alias('OVP')]
    [ValidateScript( {
        if ($null -eq $_ -or $_ -in $(&$global:TfAcSbVoiceRoutingPolicy)) { return $true } else {
          throw [System.Management.Automation.ValidationMetadataException] 'Value must be a valid Policy in the Tenant. Use Intellisense for options'
        } })]
    [ArgumentCompleter({ &$global:TfAcSbVoiceRoutingPolicy })]
    [String]$OnlineVoiceRoutingPolicy,

    [Parameter(ParameterSetName = 'TDP', HelpMessage = 'Filters based on TenantDialPlan')]
    [AllowNull()]
    [Alias('TDP')]
    [ValidateScript( {
        if ($null -eq $_ -or $_ -in $(&$global:TfAcSbTenantDialPlan)) { return $true } else {
          throw [System.Management.Automation.ValidationMetadataException] 'Value must be a valid Dial Plan in the Tenant. Use Intellisense for options'
        } })]
    [ArgumentCompleter({ &$global:TfAcSbTenantDialPlan })]
    [String]$TenantDialPlan,

    [Parameter(HelpMessage = 'Additionally also validates License (CallingPlan or PhoneSystem)')]
    [switch]$ValidateLicense

  ) #param

  begin {
    Show-FunctionStatus -Level Live
    Write-Verbose -Message "[BEGIN ] $($MyInvocation.MyCommand)"

    # Asserting AzureAD Connection
    if ( -not $script:TFPSSA) { $script:TFPSSA = Assert-AzureADConnection; if ( -not $script:TFPSSA ) { break } }

    # Asserting MicrosoftTeams Connection
    if ( -not (Assert-MicrosoftTeamsConnection) ) { break }

    # Setting Preference Variables according to Upstream settings
    if (-not $PSBoundParameters.ContainsKey('Verbose')) { $VerbosePreference = $PSCmdlet.SessionState.PSVariable.GetValue('VerbosePreference') }
    if (-not $PSBoundParameters.ContainsKey('Debug')) { $DebugPreference = $PSCmdlet.SessionState.PSVariable.GetValue('DebugPreference') } else { $DebugPreference = 'Continue' }
    if ( $PSBoundParameters.ContainsKey('InformationAction')) { $InformationPreference = $PSCmdlet.SessionState.PSVariable.GetValue('InformationAction') } else { $InformationPreference = 'Continue' }

    $Stack = Get-PSCallStack
    $Called = ($stack.length -ge 3)

  } #begin

  process {
    Write-Verbose -Message "[PROCESS] $($MyInvocation.MyCommand)"

    [System.Collections.Generic.List[object]]$Query = @()
    #region Creating Filter
    #Filter must be written as-is, e.g '$Filter = 'SipAddress -like "*{0}*"' -f $UserPrincipalName' (Get-CsOnlineUser is an Online command, handover of parameters is sketchy)
    switch ($PsCmdlet.ParameterSetName) {
      'ID' {
        Write-Information "TRYING: Finding Users with SipAddress '$UserPrincipalName'"
        #Filter currently does not support wildcards at the beginning, it is defined as 'starts-with'
        #$Filter = 'SipAddress -like "*{0}*"' -f $UserPrincipalName #Filter must be written as-is
        $Filter = 'SipAddress -like "{0}*"' -f $UserPrincipalName #Filter must be written as-is
        break
      } #ID

      'Tel' {
        Write-Verbose -Message "Normalising Input for Phone Number '$PhoneNumber'"
        if ($PhoneNumber -match '([0-9]{3,25});ext=([0-9]{3,8})') {
          $Number = $matches[1] # Phone Number
          # $Number = $matches[2] # Extension
        }
        else {
          $Number = Format-StringForUse "$($PhoneNumber.split(';')[0].split('x')[0])" -SpecialChars 'telx:+() -'
        }
        if ( -not $Called) {
          Write-Information "TRYING: Finding all Users enabled for Teams with Phone Number string '$Number': Searching..."
        }
        $Filter = 'LineURI -like "{0}*"' -f $Number #Filter must be written as-is
        break
      } #Tel

      'Ext' {
        Write-Verbose -Message "Normalising Input for Extension '$Extension'"
        if ($Extension -match '([0-9]{3,15})?;?ext=([0-9]{3,8})') {
          # $Number = $matches[1] # Phone Number
          # $Number = $matches[2] # Extension
          $ExtN = 'ext=' + $matches[2]
        }
        else {
          $ExtN = 'ext=' + $ext
        }
        if ( -not $Called) {
          Write-Information "TRYING: Finding all Users enabled for Teams with Extension '$ExtN': Searching..."
          Write-Warning -Message 'This may not work as the Filter does not support Wildcards at the beginning of the string. Nothing may be returned.'
        }
        $Filter = 'LineURI -like "*{0}*"' -f "$ExtN" #Filter must be written as-is
        break
      } #Ext

      'VP' {
        # Currently not operational - Parameter VoicePolicy is no longer exposed. Replacement is sought.
        if ( -not $Called) {
          Write-Information "TRYING: Finding all Users enabled for Teams with VoicePolicy '$VoicePolicy': Searching..."
        }
        $Filter = 'VoicePolicy -EQ "{0}"' -f $VoicePolicy #Filter must be written as-is
        #break
        continue
      } #VP

      'OVP' {
        Write-Verbose -Message "Finding OnlineVoiceRoutingPolicy '$OnlineVoiceRoutingPolicy'..."
        $OVP = Get-CsOnlineVoiceRoutingPolicy $OnlineVoiceRoutingPolicy -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
        if ($null -ne $OVP) {
          if ( -not $Called) {
            Write-Information "TRYING: OnlineVoiceRoutingPolicy found, finding all Users enabled for Teams with OVP '$OnlineVoiceRoutingPolicy': Searching..."
          }
          $Filter = 'OnlineVoiceRoutingPolicy -EQ "{0}"' -f $OnlineVoiceRoutingPolicy #Filter must be written as-is
        }
        else {
          #Write-Error -Message "OnlineVoiceRoutingPolicy '$OnlineVoiceRoutingPolicy' not found" -Category ObjectNotFound -ErrorAction Stop
          $OnlineVoiceRoutingPolicy = $("$($OnlineVoiceRoutingPolicy -replace '\*', '')" + '*')
          if ( -not $Called) {
            Write-Information "TRYING: OnlineVoiceRoutingPolicy not found, finding all Users enabled for Teams with OVP starting with '$OnlineVoiceRoutingPolicy': Searching..."
          }
          $Filter = 'OnlineVoiceRoutingPolicy -like "{0}"' -f $OnlineVoiceRoutingPolicy #Filter must be written as-is
        }
        break
      } #OVP

      'TDP' {
        Write-Verbose -Message "Finding TenantDialPlan '$TenantDialPlan'..."
        $TDP = Get-CsTenantDialPlan $TenantDialPlan -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
        if ($null -ne $TDP) {
          if ( -not $Called) {
            Write-Information "TRYING: TenantDialPlan found, finding all Users enabled for Teams with TenantDialPlan '$TenantDialPlan': Searching..."
          }
          $Filter = 'TenantDialPlan -EQ "{0}"' -f $TenantDialPlan #Filter must be written as-is
        }
        else {
          #Write-Error -Message "TenantDialPlan '$TenantDialPlan' not found" -Category ObjectNotFound -ErrorAction Stop
          $TenantDialPlan = $("$($TenantDialPlan -replace '\*', '')" + '*')
          if ( -not $Called) {
            Write-Information "TRYING: TenantDialPlan not found, finding all Users enabled for Teams with TDP starting with '$TenantDialPlan': Searching..."
          }
          $Filter = 'TenantDialPlan -like "{0}"' -f $TenantDialPlan #Filter must be written as-is
        }
        break
      } #TDP

      default {
        # No Parameter is specified
        Write-Warning -Message 'No Parameters specified. Please specify search criteria (Parameter and value)!' -Verbose
        break
      } #default
    } #Switch
    #endregion

    #region Query
    if ( $Filter ) {
      Write-Verbose -Message "[QUERY ] Performing search against Get-CsOnlineUser ($($PsCmdlet.ParameterSetName))"
      try {
        $CsUser = Get-CsOnlineUser -Filter $Filter -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
        $CsUser | ForEach-Object { [void]$Query.Add($_) }
        if ( -not $CsUser ) {
          throw [Exception] 'No Object found'
        }
      }
      catch [Exception] {
        # Optional Secondary filter option to catch an ID that is not correctly configured (UPN deviates from SIP)
        if ( $PsCmdlet.ParameterSetName -eq 'ID') {
          Write-Information "TRYING: Finding Users with UserPrincipalName '$UserPrincipalName'"
          $Filter = 'UserPrincipalName -like "{0}*"' -f $UserPrincipalName
          $CsUser = Get-CsOnlineUser -Filter $Filter -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
          $CsUser | ForEach-Object { [void]$Query.Add($_) }
        }
      }
      catch {
        Write-Error -Message "Error executing Get-CsOnlineUser: $($_.Exception.Message)" -ErrorAction Stop
      }
    }

    # Applying Secondary Filter for ConfigurationType
    if ( $PsCmdlet.ParameterSetName -eq 'CT') {
      [System.Collections.Generic.List[object]]$ConfigurationTypeUsers = @()
      switch ($ConfigurationType) {
        'DirectRouting' {
          $ConfigurationTypeObjects = $Query | Where-Object { $_.VoicePolicy -eq 'HybridVoice' -and $null -eq $_.VoiceRoutingPolicy -and ($null -ne $_.LineURI -or $null -ne $_.OnlineVoiceRoutingPolicy) }
          $ConfigurationTypeObjects | ForEach-Object { [void]$ConfigurationTypeUsers.Add( $_ ) }
        }
        'SkypeHybridPSTN' {
          #This will output overlapping with DirectRouting
          #$Query | Where-Object { $_.VoicePolicy -eq 'HybridVoice' -and $null -eq $_.OnlineVoiceRoutingPolicy -and ($null -ne $_.OnPremLineURI -or $null -ne $_.VoiceRoutingPolicy) }
          $ConfigurationTypeObjects = $Query | Where-Object { $_.VoicePolicy -eq 'HybridVoice' -and $null -eq $_.OnlineVoiceRoutingPolicy -and $null -ne $_.VoiceRoutingPolicy }
          $ConfigurationTypeObjects | ForEach-Object { [void]$ConfigurationTypeUsers.Add( $_ ) }
        }
        'CallingPlans' {
          # Secondary filter not required, but for more granularity, a TelephoneNumber (MicrosoftNumber) can be queried with -and instead:
          $ConfigurationTypeObjects = $Query | Where-Object { $_.VoicePolicy -eq 'BusinessVoice' -or $null -ne $_.TelephoneNumber }
          $ConfigurationTypeObjects | ForEach-Object { [void]$ConfigurationTypeUsers.Add( $_ ) }
        }
      }
      $Query = $ConfigurationTypeUsers
    }

    $UnfilteredCount = $Query.Count
    Write-Verbose -Message "[QUERY ] $($MyInvocation.MyCommand) - $UnfilteredCount Objects found for the filter ('$Filter')"
    if ($PSBoundParameters.ContainsKey('Debug') -or $DebugPreference -eq 'Continue') {
      " Function: $($MyInvocation.MyCommand.Name) - Unfiltered Output: ($UnfilteredCount)", ($Query | Select-Object UserPrincipalName, LineUri | Format-Table -AutoSize | Out-String).Trim() | Write-Debug
    }
    #endregion

    #region ValidateLicense
    if ( $Query -and $PSBoundParameters.ContainsKey('ValidateLicense')) {
      Write-Verbose -Message 'Verifying whether filtered Objects are correctly provisioned for PhoneSystem (assigned, enabled & provisioned successfully).'
      [System.Collections.Generic.List[object]]$LicensedUsers = @()
      foreach ($U in $Query) {
        if ( (Test-TeamsUserLicense $($U.UserPrincipalName) -ServicePlan MCOEV) ) {
          #Adding all Users that are licensed for Phone System to LicensedUsers Object
          [void]$LicensedUsers.Add($U)
        }
      }
      $Query = $LicensedUsers
    }
    $LicensedCount = $Query.Count
    Write-Verbose -Message "[QUERY ] $($MyInvocation.MyCommand) - $LicensedCount Objects found with valid license"
    if ($PSBoundParameters.ContainsKey('Debug') -or $DebugPreference -eq 'Continue') {
      " Function: $($MyInvocation.MyCommand.Name) - Filtered Output: ($LicensedCount)", ($Query | Select-Object UserPrincipalName, LineUri | Format-Table -AutoSize | Out-String).Trim() | Write-Debug
    }
    #endregion


    #region OUTPUT
    # Paging: First & Skip
    if ( $Query.Count -gt 0 ) {
      # Displaying warnings & Feedback
      if ( $Query.Count -gt 1 ) {
        switch ( $PsCmdlet.ParameterSetName) {
          'Tel' {
            Write-Warning -Message "Number: '$Number' - Found multiple Users matching the criteria! If the search string represents the FULL number, it is assigned incorrectly. Inbound calls to this number will not work as Teams will not find a unique match"
            Write-Verbose -Message 'Investigate LineURI string. Verify unique PhoneNumber is applied.' -Verbose
          }
          'Ext' {
            Write-Warning -Message "Extension: '$ExtN' - Found multiple Users matching the criteria! If the search string represents the FULL extension, it is assigned incorrectly. Inbound calls to this extension may fail depending on normalisation as Teams will not find a unique match"
            Write-Verbose -Message 'Investigate LineURI string. Verify unique Extension is applied.' -Verbose
          }
        }
      }

      # Processing paging
      $FirstId = 0
      $LastId = $LicensedCount - 1
      if ($PSCmdlet.PagingParameters.Skip -ge $Query.count) {
        Write-Verbose -Message "[PAGING ] $($MyInvocation.MyCommand) - No results satisfy the Skip parameters"
      }
      elseif ($PSCmdlet.PagingParameters.First -eq 0) {
        Write-Verbose -Message "[PAGING ] $($MyInvocation.MyCommand) - No results satisfy the First parameters"
      }
      else {
        $FirstId = $PSCmdlet.PagingParameters.Skip
        Write-Verbose -Message ("[PAGING ] $($MyInvocation.MyCommand) - FirstId: {0}" -f $FirstId)
        $LastId = $FirstId + ([Math]::Min($PSCmdlet.PagingParameters.First, $Query.Count - $PSCmdlet.PagingParameters.Skip) - 1)
      }
      if ($PSBoundParameters.ContainsKey('Debug') -or $DebugPreference -eq 'Continue') {
        " Function: $($MyInvocation.MyCommand.Name) - Queried: $($Query.Count)" | Write-Debug
        " Function: $($MyInvocation.MyCommand.Name) - FirstId: $FirstId" | Write-Debug
        " Function: $($MyInvocation.MyCommand.Name) - LastId: $LastId" | Write-Debug
      }
      $Query = $Query[$FirstId..$LastId]
      $FilteredCount = $Query.Count
      if ($PSBoundParameters.ContainsKey('Debug') -or $DebugPreference -eq 'Continue') {
        " Function: $($MyInvocation.MyCommand.Name) - Paginated Output: ($FilteredCount)", ($Query | Select-Object UserPrincipalName, LineUri | Format-Table -AutoSize | Out-String).Trim() | Write-Debug
      }

      if ($Query) {
        if ($Query.Count -gt 3 ) {
          $Query | Select-Object UserPrincipalName, SipAddress, LineUri
        }
        else {
          $Query.UserPrincipalName | Get-TeamsUserVoiceConfig
        }
      }
      Write-Verbose -Message ("[PAGING ] $($MyInvocation.MyCommand) - LastId: {0}" -f $LastId)
    }
    elseif ( -not $Called) {
      Write-Verbose -Message "[QUERY ] $($MyInvocation.MyCommand) - No results found ($($PsCmdlet.ParameterSetName))" -Verbose
    }
    #endregion
  } #process

  end {
    Write-Verbose -Message "[END ] $($MyInvocation.MyCommand)"
    # Paging: IncludeTotalCount
    If ($PSCmdlet.PagingParameters.IncludeTotalCount) {
      [double]$Accuracy = 1.0
      $PSCmdlet.PagingParameters.NewTotalCount($FilteredCount, $Accuracy)
      if ( $FilteredCount ) {
        if ( $FilteredCount -lt $UnfilteredCount ) {
          Write-Information "INFO: A total of $UnfilteredCount objects have been found$( if ( $ValidateLicense ) { " ($LicensedCount licensed correctly)"}), but only the requested $FilteredCount object(s) are displayed."
        }
      }
    }
  } #end
} # Find-TeamsUserVoiceConfig