d365bap.tools.psm1

$script:ModuleRoot = $PSScriptRoot
$script:ModuleVersion = (Import-PowerShellDataFile -Path "$($script:ModuleRoot)\d365bap.tools.psd1").ModuleVersion

# Detect whether at some level dotsourcing was enforced
$script:doDotSource = Get-PSFConfigValue -FullName d365bap.tools.Import.DoDotSource -Fallback $false
if ($d365bap.tools_dotsourcemodule) { $script:doDotSource = $true }

<#
Note on Resolve-Path:
All paths are sent through Resolve-Path/Resolve-PSFPath in order to convert them to the correct path separator.
This allows ignoring path separators throughout the import sequence, which could otherwise cause trouble depending on OS.
Resolve-Path can only be used for paths that already exist, Resolve-PSFPath can accept that the last leaf my not exist.
This is important when testing for paths.
#>


# Detect whether at some level loading individual module files, rather than the compiled module was enforced
$importIndividualFiles = Get-PSFConfigValue -FullName d365bap.tools.Import.IndividualFiles -Fallback $false
if ($d365bap.tools_importIndividualFiles) { $importIndividualFiles = $true }
if (Test-Path (Resolve-PSFPath -Path "$($script:ModuleRoot)\..\.git" -SingleItem -NewChild)) { $importIndividualFiles = $true }
if ("<was compiled>" -eq '<was not compiled>') { $importIndividualFiles = $true }
    
function Import-ModuleFile
{
    <#
        .SYNOPSIS
            Loads files into the module on module import.
         
        .DESCRIPTION
            This helper function is used during module initialization.
            It should always be dotsourced itself, in order to proper function.
             
            This provides a central location to react to files being imported, if later desired
         
        .PARAMETER Path
            The path to the file to load
         
        .EXAMPLE
            PS C:\> . Import-ModuleFile -File $function.FullName
     
            Imports the file stored in $function according to import policy
    #>

    [CmdletBinding()]
    Param (
        [string]
        $Path
    )
    
    $resolvedPath = $ExecutionContext.SessionState.Path.GetResolvedPSPathFromPSPath($Path).ProviderPath
    if ($doDotSource) { . $resolvedPath }
    else { $ExecutionContext.InvokeCommand.InvokeScript($false, ([scriptblock]::Create([io.file]::ReadAllText($resolvedPath))), $null, $null) }
}

#region Load individual files
if ($importIndividualFiles)
{
    # Execute Preimport actions
    foreach ($path in (& "$ModuleRoot\internal\scripts\preimport.ps1")) {
        . Import-ModuleFile -Path $path
    }
    
    # Import all internal functions
    foreach ($function in (Get-ChildItem "$ModuleRoot\internal\functions" -Filter "*.ps1" -Recurse -ErrorAction Ignore))
    {
        . Import-ModuleFile -Path $function.FullName
    }
    
    # Import all public functions
    foreach ($function in (Get-ChildItem "$ModuleRoot\functions" -Filter "*.ps1" -Recurse -ErrorAction Ignore))
    {
        . Import-ModuleFile -Path $function.FullName
    }
    
    # Execute Postimport actions
    foreach ($path in (& "$ModuleRoot\internal\scripts\postimport.ps1")) {
        . Import-ModuleFile -Path $path
    }
    
    # End it here, do not load compiled code below
    return
}
#endregion Load individual files

#region Load compiled code
<#
This file loads the strings documents from the respective language folders.
This allows localizing messages and errors.
Load psd1 language files for each language you wish to support.
Partial translations are acceptable - when missing a current language message,
it will fallback to English or another available language.
#>

Import-PSFLocalizedString -Path "$($script:ModuleRoot)\en-us\*.psd1" -Module 'd365bap.tools' -Language 'en-US'


<#
    .SYNOPSIS
        Get language from Environment
         
    .DESCRIPTION
        Fetches all languages from the environment
         
    .PARAMETER BaseUri
        Base Web API URI for the environment
         
        Used to construct the correct REST API Url, based on the WebApi / OData endpoint
         
    .EXAMPLE
        PS C:\> Get-EnvironmentLanguage -BaseUri 'https://temp-test.crm4.dynamics.com'
         
        This will fetch all languages from the environment.
        Uses the WebAPI / OData endpoint.
         
    .NOTES
        Author: Mötz Jensen (@Splaxi)
#>

function Get-EnvironmentLanguage {
    [CmdletBinding()]
    param (
        [parameter (mandatory = $true)]
        [string] $BaseUri
    )

    begin {
        $tokenWebApi = Get-AzAccessToken -ResourceUrl $BaseUri
        $headersWebApi = @{
            "Authorization" = "Bearer $($tokenWebApi.Token)"
        }

        $resOrg = Invoke-RestMethod -Method Get -Uri $($baseUri + '/api/data/v9.2/organizations?$select=organizationid,orgdborgsettings,languagecode,localeid,name') -Headers $headersWebApi | Select-Object -ExpandProperty value | Select-Object -First 1
        $resLangs = Invoke-RestMethod -Method Get -Uri "$BaseUri/api/data/v9.2/languagelocale" -Headers $headersWebApi | Select-Object -ExpandProperty value
    }

    process {
        foreach ($lanObj in $resLangs) {
            if ($lanObj.localeid -eq $resOrg.localeid) {
                # Could also be "languagecode" - maybe we'll get more info later on
                $lanObj | Add-Member -MemberType NoteProperty -Name "BaseLocaleId" -Value 0
            }
        }

        $resLangs
    }
}


<#
    .SYNOPSIS
        Compare environment D365 Apps
         
    .DESCRIPTION
        This enables the user to compare 2 x environments, with one as a source and the other as a destination
         
        It will only look for installed D365 Apps on the source, and use this as a baseline against the destination
         
    .PARAMETER SourceEnvironmentId
        Environment Id of the source environment that you want to utilized as the baseline for the compare
         
    .PARAMETER DestinationEnvironmentId
        Environment Id of the destination environment that you want to validate against the baseline (source)
         
    .PARAMETER ShowDiffOnly
        Instruct the cmdlet to only output the differences that are not aligned between the source and destination
         
    .PARAMETER GeoRegion
        Instructs the cmdlet which Geo / Region the environment is located
         
        The default value is: "Emea"
         
        This is mandatory field from the API specification, we don't have the full list of values at the time of writing
         
    .PARAMETER AsExcelOutput
        Instruct the cmdlet to output all details directly to an Excel file
         
        This makes it easier to deep dive into all the details returned from the API, and makes it possible for the user to persist the current state
         
    .EXAMPLE
        PS C:\> Compare-BapEnvironmentD365App -SourceEnvironmentId eec2c11a-a4c7-4e1d-b8ed-f62acc9c74c6 -DestinationEnvironmentId 32c6b196-ef52-4c43-93cf-6ecba51e6aa1
         
        This will get all installed D365 Apps from the Source Environment.
        It will iterate over all of them, and validate against the Destination Environment.
         
        Sample output:
        PackageId PackageName SourceVersion DestinationVersion AppName
        --------- ----------- ------------- ------------------ -------
        ea8d3b2f-ede2-46b4-900d-ed02c81c44fd AgentProductivityToolsAnchor 9.2.24021.1005 9.2.24012.1005 Agent Prod…
        1c0a1237-9408-4b99-9fec-39696d99287b msdyn_AppProfileManagerAnchor 10.1.24021.1005 10.1.24012.1013 appprofile…
        6ce2d70e-78bf-4ff6-85ed-1bd63d4ab444 ExportToDataLakeCoreAnchor 1.0.0.1 0.0.0.0 Azure Syna…
        42cc1442-194f-462b-a325-ce5b5f18c02d msdyn_EmailAddressValidation 1.0.0.4 1.0.0.4 Data Valid…
         
    .EXAMPLE
        PS C:\> Compare-BapEnvironmentD365App -SourceEnvironmentId eec2c11a-a4c7-4e1d-b8ed-f62acc9c74c6 -DestinationEnvironmentId 32c6b196-ef52-4c43-93cf-6ecba51e6aa1 -ShowDiffOnly
         
        This will get all installed D365 Apps from the Source Environment.
        It will iterate over all of them, and validate against the Destination Environment.
        It will filter out results, to only include those where the DestinationVersions is different from the SourceVersion.
         
        Sample output:
        PackageId PackageName SourceVersion DestinationVersion AppName
        --------- ----------- ------------- ------------------ -------
        ea8d3b2f-ede2-46b4-900d-ed02c81c44fd AgentProductivityToolsAnchor 9.2.24021.1005 9.2.24012.1005 Agent Prod…
        1c0a1237-9408-4b99-9fec-39696d99287b msdyn_AppProfileManagerAnchor 10.1.24021.1005 10.1.24012.1013 appprofile…
        6ce2d70e-78bf-4ff6-85ed-1bd63d4ab444 ExportToDataLakeCoreAnchor 1.0.0.1 0.0.0.0 Azure Syna…
        7523d261-f1be-46e7-8e68-f3de16eeabbb DualWriteCoreAnchor 1.0.24022.4 1.0.24011.1 Dual-write…
         
    .EXAMPLE
        PS C:\> Compare-BapEnvironmentD365App -SourceEnvironmentId eec2c11a-a4c7-4e1d-b8ed-f62acc9c74c6 -DestinationEnvironmentId 32c6b196-ef52-4c43-93cf-6ecba51e6aa1 -AsExcelOutput
         
        This will get all installed D365 Apps from the Source Environment.
        It will iterate over all of them, and validate against the Destination Environment.
        Will output all details into an Excel file, that will auto open on your machine.
         
    .NOTES
        Author: Mötz Jensen (@Splaxi)
#>

function Compare-BapEnvironmentD365App {
    [CmdletBinding()]
    param (
        [parameter (mandatory = $true)]
        [string] $SourceEnvironmentId,

        [parameter (mandatory = $true)]
        [string] $DestinationEnvironmentId,
    
        [switch] $ShowDiffOnly,

        [string] $GeoRegion = "Emea",

        [switch] $AsExcelOutput

    )
    
    begin {
        # Make sure all *BapEnvironment* cmdlets will validate that the environment exists prior running anything.
        $envSourceObj = Get-BapEnvironment -EnvironmentId $SourceEnvironmentId | Select-Object -First 1

        if ($null -eq $envSourceObj) {
            $messageString = "The supplied SourceEnvironmentId: <c='em'>$SourceEnvironmentId</c> didn't return any matching environment details. Please verify that the SourceEnvironmentId is correct - try running the <c='em'>Get-BapEnvironment</c> cmdlet."
            Write-PSFMessage -Level Host -Message $messageString
            Stop-PSFFunction -Message "Stopping because environment was NOT found based on the id." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', '')))
        }
        
        # Make sure all *BapEnvironment* cmdlets will validate that the environment exists prior running anything.
        $envDestinationObj = Get-BapEnvironment -EnvironmentId $DestinationEnvironmentId | Select-Object -First 1

        if ($null -eq $envDestinationObj) {
            $messageString = "The supplied DestinationEnvironmentId: <c='em'>$DestinationEnvironmentId</c> didn't return any matching environment details. Please verify that the DestinationEnvironmentId is correct - try running the <c='em'>Get-BapEnvironment</c> cmdlet."
            Write-PSFMessage -Level Host -Message $messageString
            Stop-PSFFunction -Message "Stopping because environment was NOT found based on the id." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', '')))
        }

        if (Test-PSFFunctionInterrupt) { return }

        $appsSourceEnvironment = Get-BapEnvironmentD365App -EnvironmentId $SourceEnvironmentId -InstallState Installed -$GeoRegion $GeoRegion
        $appsDestinationEnvironment = Get-BapEnvironmentD365App -EnvironmentId $DestinationEnvironmentId -$GeoRegion $GeoRegion
    }
    
    process {
        if (Test-PSFFunctionInterrupt) { return }

        $resCol = @(foreach ($sourceApp in $($appsSourceEnvironment | Sort-Object -Property ApplicationName )) {
                $destinationApp = $appsDestinationEnvironment | Where-Object PackageId -eq $sourceApp.PackageId | Select-Object -First 1
        
                $tmp = [Ordered]@{
                    PackageId          = $sourceApp.PackageId
                    PackageName        = $sourceApp.PackageName
                    AppName            = $sourceApp.AppName
                    SourceVersion      = [System.Version]$sourceApp.InstalledVersion
                    DestinationVersion = "Missing"
                }
        
                if (-not ($null -eq $destinationApp)) {
                    $tmp.DestinationVersion = if ($destinationApp.InstalledVersion -eq "N/A") { [System.Version]"0.0.0.0" }else { [System.Version]$destinationApp.InstalledVersion }
                }
        
                ([PSCustomObject]$tmp) | Select-PSFObject -TypeName "D365Bap.Tools.Compare.Package"
            }
        )

        if ($ShowDiffOnly) {
            $resCol = $resCol | Where-Object { $_.SourceVersion -ne $_.DestinationVersion }
        }

        if ($AsExcelOutput) {
            $resCol | Export-Excel -NoNumberConversion SourceVersion, DestinationVersion
            return
        }

        $resCol
    }
    
    end {
        
    }
}


<#
    .SYNOPSIS
        Compare the environment users
         
    .DESCRIPTION
        This enables the user to compare 2 x environments, with one as a source and the other as a destination
         
        It will only look for users on the source, and use this as a baseline against the destination
         
    .PARAMETER SourceEnvironmentId
        Environment Id of the source environment that you want to utilized as the baseline for the compare
         
    .PARAMETER DestinationEnvironmentId
        Environment Id of the destination environment that you want to validate against the baseline (source)
         
    .PARAMETER ShowDiffOnly
        Instruct the cmdlet to only output the differences that are not aligned between the source and destination
         
    .PARAMETER IncludeAppIds
        Instruct the cmdlet to also include the users with the ApplicationId property filled
         
    .PARAMETER AsExcelOutput
        Instruct the cmdlet to output all details directly to an Excel file
         
        This makes it easier to deep dive into all the details returned from the API, and makes it possible for the user to persist the current state
         
    .EXAMPLE
        PS C:\> Compare-BapEnvironmentD365App -SourceEnvironmentId eec2c11a-a4c7-4e1d-b8ed-f62acc9c74c6 -DestinationEnvironmentId 32c6b196-ef52-4c43-93cf-6ecba51e6aa1
         
        This will get all system users from the Source Environment.
        It will iterate over all of them, and validate against the Destination Environment.
        It will exclude those with ApplicationId filled.
         
        Sample output:
        Email Name AppId SourceId DestinationId
        ----- ---- ----- -------- -------------
        aba@temp.com Austin Baker f85bcd69-ef72-… 5aaac0ec-a91…
        ade@temp.com Alex Denver 39309a5c-7676-… 1d521227-43b…
         
    .EXAMPLE
        PS C:\> Compare-BapEnvironmentD365App -SourceEnvironmentId eec2c11a-a4c7-4e1d-b8ed-f62acc9c74c6 -DestinationEnvironmentId 32c6b196-ef52-4c43-93cf-6ecba51e6aa1 -IncludeAppIds
         
        This will get all system users from the Source Environment.
        It will iterate over all of them, and validate against the Destination Environment.
        It will include those with ApplicationId filled.
         
        Sample output:
        Email Name AppId SourceId DestinationId
        ----- ---- ----- -------- -------------
        aba@temp.com Austin Baker f85bcd69-ef72-… 5aaac0ec-a91…
        ade@temp.com Alex Denver 39309a5c-7676-… 1d521227-43b…
        AIBuilder_StructuredML_Prod_C… AIBuilder_StructuredML_Prod_C… ff8a1ad8-a415-45c1-… 95dc9ca2-8185-… 328db0cc-14c…
        AIBuilderProd@onmicrosoft.com AIBuilderProd, # 0a143f2d-2320-4141-… c96f82b8-320f-… 1831f4dc-4c5…
         
    .EXAMPLE
        PS C:\> Compare-BapEnvironmentD365App -SourceEnvironmentId eec2c11a-a4c7-4e1d-b8ed-f62acc9c74c6 -DestinationEnvironmentId 32c6b196-ef52-4c43-93cf-6ecba51e6aa1 -IncludeAppIds -ShowDiffOnly
         
        This will get all system users from the Source Environment.
        It will iterate over all of them, and validate against the Destination Environment.
        It will include those with ApplicationId filled.
        It will only output the users that is missing in the destionation environment.
         
        Sample output:
        Email Name AppId SourceId DestinationId
        ----- ---- ----- -------- -------------
        d365-scm-operationdataservice… d365-scm-operationdataservice… 986556ed-a409-4339-… 5e077e6a-a0c9-… Missing
        d365-scm-operationdataservice… d365-scm-operationdataservice… 14e80222-1878-455d-… 183ec023-9ccb-… Missing
        def@temp.com Dustin Effect 01e37132-0a44-… Missing
         
    .EXAMPLE
        PS C:\> Compare-BapEnvironmentD365App -SourceEnvironmentId eec2c11a-a4c7-4e1d-b8ed-f62acc9c74c6 -DestinationEnvironmentId 32c6b196-ef52-4c43-93cf-6ecba51e6aa1 -AsExcelOutput
         
        This will get all system users from the Source Environment.
        It will iterate over all of them, and validate against the Destination Environment.
        It will exclude those with ApplicationId filled.
        Will output all details into an Excel file, that will auto open on your machine.
         
    .NOTES
        Author: Mötz Jensen (@Splaxi)
#>

function Compare-BapEnvironmentUser {
    [CmdletBinding()]
    param (
        [parameter (mandatory = $true)]
        [string] $SourceEnvironmentId,

        [parameter (mandatory = $true)]
        [string] $DestinationEnvironmentId,
    
        [switch] $ShowDiffOnly,

        [switch] $IncludeAppIds,

        [switch] $AsExcelOutput

    )
    
    begin {
        # Make sure all *BapEnvironment* cmdlets will validate that the environment exists prior running anything.
        $envSourceObj = Get-BapEnvironment -EnvironmentId $SourceEnvironmentId | Select-Object -First 1

        if ($null -eq $envSourceObj) {
            $messageString = "The supplied SourceEnvironmentId: <c='em'>$SourceEnvironmentId</c> didn't return any matching environment details. Please verify that the SourceEnvironmentId is correct - try running the <c='em'>Get-BapEnvironment</c> cmdlet."
            Write-PSFMessage -Level Host -Message $messageString
            Stop-PSFFunction -Message "Stopping because environment was NOT found based on the id." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', '')))
        }
        
        # Make sure all *BapEnvironment* cmdlets will validate that the environment exists prior running anything.
        $envDestinationObj = Get-BapEnvironment -EnvironmentId $DestinationEnvironmentId | Select-Object -First 1

        if ($null -eq $envDestinationObj) {
            $messageString = "The supplied DestinationEnvironmentId: <c='em'>$DestinationEnvironmentId</c> didn't return any matching environment details. Please verify that the DestinationEnvironmentId is correct - try running the <c='em'>Get-BapEnvironment</c> cmdlet."
            Write-PSFMessage -Level Host -Message $messageString
            Stop-PSFFunction -Message "Stopping because environment was NOT found based on the id." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', '')))
        }

        if (Test-PSFFunctionInterrupt) { return }

        $usersSourceEnvironment = Get-BapEnvironmentUser -EnvironmentId $SourceEnvironmentId -IncludeAppIds:$IncludeAppIds
        $usersDestinationEnvironment = Get-BapEnvironmentUser -EnvironmentId $DestinationEnvironmentId -IncludeAppIds:$IncludeAppIds
    }
    
    process {
        if (Test-PSFFunctionInterrupt) { return }

        $resCol = @(foreach ($sourceUser in $($usersSourceEnvironment | Sort-Object -Property Email )) {
                if ([System.String]::IsNullOrEmpty($sourceUser.Email)) { continue }

                $destinationUser = $usersDestinationEnvironment | Where-Object Email -eq $sourceUser.Email | Select-Object -First 1
        
                $tmp = [Ordered]@{
                    Email         = $sourceUser.Email
                    Name          = $sourceUser.Name
                    AppId         = $sourceUser.AppId
                    SourceId      = $sourceUser.systemuserid
                    DestinationId = "Missing"
                }
        
                if (-not ($null -eq $destinationUser)) {
                    $tmp.DestinationId = $destinationUser.systemuserid
                }
        
                ([PSCustomObject]$tmp) | Select-PSFObject -TypeName "D365Bap.Tools.Compare.User"
            }
        )

        if ($ShowDiffOnly) {
            $resCol = $resCol | Where-Object { $_.DestinationId -eq "Missing" }
        }

        if ($AsExcelOutput) {
            $resCol | Export-Excel -NoNumberConversion SourceVersion, DestinationVersion
            return
        }

        $resCol
    }
    
    end {
        
    }
}


<#
    .SYNOPSIS
        Get environment info
         
    .DESCRIPTION
        This enables the user to query and validate all environments that are available from inside PPAC
         
        It utilizes the "https://api.bap.microsoft.com" REST API
         
    .PARAMETER EnvironmentId
        The id of the environment that you want to work against
         
    .PARAMETER AsExcelOutput
        Instruct the cmdlet to output all details directly to an Excel file
         
        This makes it easier to deep dive into all the details returned from the API, and makes it possible for the user to persist the current state
         
    .EXAMPLE
        PS C:\> Get-BapEnvironment
         
        This will query for ALL available environments.
         
        Sample output:
        PpacEnvId PpacEnvRegion PpacEnvName PpacEnvSku LinkedAppLcsEnvUri
        --------- ------------- ----------- ---------- ------------------
        32c6b196-ef52-4c43-93cf-6ecba51e6aa1 europe new-uat Sandbox https://new-uat.sandbox.operatio…
        eec2c11a-a4c7-4e1d-b8ed-f62acc9c74c6 europe new-test Sandbox https://new-test.sandbox.operati…
        d45936a7-0408-4b79-94d1-19e4c6e5a52e europe new-golden Sandbox https://new-golden.sandbox.opera…
        Default-e210bc90-e54b-4544-a9b8-b1f… europe New Customer Default
         
    .EXAMPLE
        PS C:\> Get-BapEnvironment -EnvironmentId eec2c11a-a4c7-4e1d-b8ed-f62acc9c74c6
         
        This will query for the specific environment.
         
        Sample output:
        PpacEnvId PpacEnvRegion PpacEnvName PpacEnvSku LinkedAppLcsEnvUri
        --------- ------------- ----------- ---------- ------------------
        eec2c11a-a4c7-4e1d-b8ed-f62acc9c74c6 europe new-test Sandbox https://new-test.sandbox.operati…
         
    .EXAMPLE
        PS C:\> Get-BapEnvironment -AsExcelOutput
         
        This will query for ALL available environments.
        Will output all details into an Excel file, that will auto open on your machine.
         
    .NOTES
        Author: Mötz Jensen (@Splaxi)
#>

function Get-BapEnvironment {
    [CmdletBinding()]
    [OutputType('System.Object[]')]
    param (
        [string] $EnvironmentId = "*",

        [switch] $AsExcelOutput
    )
    
    begin {
        $tokenBap = Get-AzAccessToken -ResourceUrl "https://service.powerapps.com/"
        $headers = @{
            "Authorization" = "Bearer $($tokenBap.Token)"
        }
        
        $resEnvs = Invoke-RestMethod -Method Get -Uri "https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/scopes/admin/environments?api-version=2023-06-01" -Headers $headers | Select-Object -ExpandProperty Value
    }
    
    process {
        
        $resCol = @(
            foreach ($envObj in $resEnvs) {
                if (-not ($envObj.Name -like $EnvironmentId)) { continue }

                $res = [ordered]@{}

                $res.Id = $envObj.Name
                $res.Region = $envObj.Location
            
                foreach ($prop in $envObj.Properties.PsObject.Properties) {
                    if ($prop.Value -is [System.Management.Automation.PSCustomObject]) {
                        $res."prop_$($prop.Name)" = @(
                            foreach ($inner in $prop.Value.PsObject.Properties) {
                                "$($inner.Name)=$($inner.Value)"
                            }) -join "`r`n"
                    }
                    else {
                        $res."prop_$($prop.Name)" = $prop.Value
                    }
                
                }

            ([PSCustomObject]$res) | Select-PSFObject -TypeName "D365Bap.Tools.Environment" -Property "Id as PpacEnvId",
                "Region as PpacEnvRegion",
                "prop_tenantId as TenantId",
                "prop_azureRegion as AzureRegion",
                "prop_displayName as PpacEnvName",
                @{Name = "DeployedBy"; Expression = { $envObj.Properties.createdBy.userPrincipalName } },
                "prop_provisioningState as PpacProvisioningState",
                "prop_environmentSku as PpacEnvSku",
                "prop_databaseType as PpacDbType",
                @{Name = "LinkedAppLcsEnvId"; Expression = { $envObj.Properties.linkedAppMetadata.id } },
                @{Name = "LinkedAppLcsEnvUri"; Expression = { $envObj.Properties.linkedAppMetadata.url } },
                @{Name = "LinkedMetaPpacOrgId"; Expression = { $envObj.Properties.linkedEnvironmentMetadata.resourceId } },
                @{Name = "LinkedMetaPpacUniqueId"; Expression = { $envObj.Properties.linkedEnvironmentMetadata.uniqueName } },
                @{Name = "LinkedMetaPpacEnvUri"; Expression = { $envObj.Properties.linkedEnvironmentMetadata.instanceUrl -replace "com/", "com" } },
                @{Name = "LinkedMetaPpacEnvLanguage"; Expression = { $envObj.Properties.linkedEnvironmentMetadata.baseLanguage } },
                @{Name = "PpacClusterIsland"; Expression = { $envObj.Properties.cluster.uriSuffix } },
                "*"
            }
        )

        if ($AsExcelOutput) {
            $resCol | Export-Excel -NoNumberConversion Version, AvailableVersion, InstalledVersion, crmMinversion, crmMaxVersion, Version
            return
        }

        $resCol
    }
    
    end {
        
    }
}


<#
    .SYNOPSIS
        Get application users from environment
         
    .DESCRIPTION
        Enables the user to fetch all application users from the environment
         
        Utilizes the built-in "applicationusers" OData entity
         
    .PARAMETER EnvironmentId
        The id of the environment that you want to work against
         
        This can be obtained from the Get-BapEnvironment cmdlet
         
    .PARAMETER AsExcelOutput
        Instruct the cmdlet to output all details directly to an Excel file
         
        This makes it easier to deep dive into all the details returned from the API, and makes it possible for the user to persist the current state
         
    .EXAMPLE
        PS C:\> Get-BapEnvironmentApplicationUser -EnvironmentId eec2c11a-a4c7-4e1d-b8ed-f62acc9c74c6
         
        This will fetch all ApplicationUsers from the environment.
         
        Sample output:
        AppId AppName ApplicationUserId SolutionId
        ----- ------- ----------------- ----------
        b6e52ceb-f771-41ff-bd99-917523b28eaf AIBuilder_StructuredML_Prod_C… 3bafba76-60bf-413d-a4c4-5c49ccabfb12 bf85e0c8-aa47…
        21ceaf7c-054c-43f6-8b14-ef6d04b90a21 AIBuilderProd 560c9a6c-4535-4066-a415-480d1493cf98 bf85e0c8-aa47…
        c76313fd-5c6f-4f1f-9869-c884fa7fe226 AppDeploymentOrchestration d88a3535-ebf0-4b2b-ad23-90e686660a64 99aee001-009e…
        29494271-7e38-4433-8bf8-06d335299a17 AriaMdlExporter 8bf8862f-5036-42b0-a4f8-1b638db7896b 99aee001-009e…
         
    .EXAMPLE
        PS C:\> Get-BapEnvironmentApplicationUser -EnvironmentId eec2c11a-a4c7-4e1d-b8ed-f62acc9c74c6 -AsExcelOutput
         
        This will fetch all ApplicationUsers from the environment.
        Will output all details into an Excel file, that will auto open on your machine.
         
    .NOTES
        Author: Mötz Jensen (@Splaxi)
#>

function Get-BapEnvironmentApplicationUser {
    [CmdletBinding()]
    [OutputType('System.Object[]')]
    param (
        [parameter (mandatory = $true)]
        [string] $EnvironmentId,

        [switch] $AsExcelOutput
    )
    
    begin {
        # Make sure all *BapEnvironment* cmdlets will validate that the environment exists prior running anything.
        $envObj = Get-BapEnvironment -EnvironmentId $EnvironmentId | Select-Object -First 1

        if ($null -eq $envObj) {
            $messageString = "The supplied EnvironmentId: <c='em'>$EnvironmentId</c> didn't return any matching environment details. Please verify that the EnvironmentId is correct - try running the <c='em'>Get-BapEnvironment</c> cmdlet."
            Write-PSFMessage -Level Host -Message $messageString
            Stop-PSFFunction -Message "Stopping because environment was NOT found based on the id." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', '')))
        }
        
        if (Test-PSFFunctionInterrupt) { return }

        $baseUri = $envObj.LinkedMetaPpacEnvUri
        $tokenWebApi = Get-AzAccessToken -ResourceUrl $baseUri
        $headersWebApi = @{
            "Authorization" = "Bearer $($tokenWebApi.Token)"
        }

    }
    
    process {
        $resAppUsers = Invoke-RestMethod -Method Get -Uri $($baseUri + '/api/data/v9.2/applicationusers') -Headers $headersWebApi
        $resCol = @(
            foreach ($appUsrObj in  $($resAppUsers.value | Sort-Object -Property applicationname)) {
                $appUsrObj | Select-PSFObject -TypeName "D365Bap.Tools.AppUser" -ExcludeProperty "@odata.etag" -Property "applicationid as AppId",
                "applicationname as AppName",
                *
            }
        )

        if ($AsExcelOutput) {
            $resCol | Export-Excel
            return
        }

        $resCol
    }

    end {
        
    }
    
}


<#
    .SYNOPSIS
        Get D365 App from the environment
         
    .DESCRIPTION
        This enables the user to analyze and validate the current D365 Apps and their state, on a given environment
         
        It can show all available D365 Apps - including their InstallState
         
        It can show only installed D365 Apps
         
        It can show only installed D365 Apps, with available updates
         
    .PARAMETER EnvironmentId
        The id of the environment that you want to work against
         
        This can be obtained from the Get-BapEnvironment cmdlet
         
    .PARAMETER Name
        Name of the D365 App / Package that you are looking for
         
        It supports wildcard searching, which is validated against the following properties:
        * AppName / ApplicationName
        * PackageName / UniqueName
         
    .PARAMETER InstallState
        Instruct the cmdlet which install states that you want to have included in the output
         
        The default value is: "All"
         
        Valid values:
        * "All"
        * "Installed"
        * "None"
         
    .PARAMETER GeoRegion
        Instructs the cmdlet which Geo / Region the environment is located
         
        The default value is: "Emea"
         
        This is mandatory field from the API specification, we don't have the full list of values at the time of writing
         
    .PARAMETER UpdatesOnly
        Instruct the cmdlet to only output D365 Apps that has an update available
         
        Makes it easier to fully automate the update process of a given environment
         
    .PARAMETER AsExcelOutput
        Instruct the cmdlet to output all details directly to an Excel file
         
        This makes it easier to deep dive into all the details returned from the API, and makes it possible for the user to persist the current state
         
    .EXAMPLE
        PS C:\> Get-BapEnvironmentD365App -EnvironmentId eec2c11a-a4c7-4e1d-b8ed-f62acc9c74c6
         
        This will query the environment for ALL available D365 Apps.
        It will compare available vs installed D365 Apps, and indicate whether an update is available of not.
         
        Sample output:
         
        PackageId PackageName AvailableVersion InstalledVersion UpdateAvailable
        --------- ----------- ---------------- ---------------- ---------------
        cea6753e-9c74-4aa9-85a1-5869105115d3 msdyn_ExportControlAnchor 1.0.2553.1 N/A
        ea8d3b2f-ede2-46b4-900d-ed02c81c44fd AgentProductivityToolsAnchor 9.2.24021.1005 9.2.24019.1005 True
        b1676368-b448-4fbd-a238-9b6ddc36be81 SharePointFormProcessing 202209.5.2901.0 N/A
        1c0a1237-9408-4b99-9fec-39696d99287b msdyn_AppProfileManagerAnchor 10.1.24021.1005 10.1.24021.1005 False
        9f4c778b-2f0b-416f-8166-e96da680ffb2 mpa_AwardsAndRecognition 1.0.0.32 N/A
        6ce2d70e-78bf-4ff6-85ed-1bd63d4ab444 ExportToDataLakeCoreAnchor 1.0.0.1 1.0.0.1 False
         
    .EXAMPLE
        PS C:\> Get-BapEnvironmentD365App -EnvironmentId eec2c11a-a4c7-4e1d-b8ed-f62acc9c74c6 -InstallState Installed
         
        This will query the environment for installed only D365 Apps.
        It will compare available vs installed D365 Apps, and indicate whether an update is available of not.
         
        Sample output:
        PackageId PackageName AvailableVersion InstalledVersion UpdateAvailable
        --------- ----------- ---------------- ---------------- ---------------
        ea8d3b2f-ede2-46b4-900d-ed02c81c44fd AgentProductivityToolsAnchor 9.2.24021.1005 9.2.24019.1005 True
        1c0a1237-9408-4b99-9fec-39696d99287b msdyn_AppProfileManagerAnchor 10.1.24021.1005 10.1.24021.1005 False
        6ce2d70e-78bf-4ff6-85ed-1bd63d4ab444 ExportToDataLakeCoreAnchor 1.0.0.1 1.0.0.1 False
         
    .EXAMPLE
        PS C:\> Get-BapEnvironmentD365App -EnvironmentId eec2c11a-a4c7-4e1d-b8ed-f62acc9c74c6 -InstallState None
         
        This will query the environment for NON-installed only D365 Apps.
        It will output all details available for the D365 Apps.
         
        Sample output:
        PackageId PackageName AvailableVersion InstalledVersion UpdateAvailable
        --------- ----------- ---------------- ---------------- ---------------
        cea6753e-9c74-4aa9-85a1-5869105115d3 msdyn_ExportControlAnchor 1.0.2553.1 N/A
        b1676368-b448-4fbd-a238-9b6ddc36be81 SharePointFormProcessing 202209.5.2901.0 N/A
        9f4c778b-2f0b-416f-8166-e96da680ffb2 mpa_AwardsAndRecognition 1.0.0.32 N/A
         
    .EXAMPLE
        PS C:\> Get-BapEnvironmentD365App -EnvironmentId eec2c11a-a4c7-4e1d-b8ed-f62acc9c74c6 -Name "*ProviderAnchor*"
         
        This will query the environment for ALL D365 Apps.
        It will filter the output to only those who match the search pattern "*ProviderAnchor*".
        It will compare available vs installed D365 Apps, and indicate whether an update is available of not.
         
        Sample output:
        PackageId PackageName AvailableVersion InstalledVersion UpdateAvailable
        --------- ----------- ---------------- ---------------- ---------------
        c0cb37fd-d7f4-40f2-8592-64ec71a2c508 msft_ConnectorProviderAnchor 9.0.0.1618 9.0.0.1618 False
         
    .EXAMPLE
        PS C:\> Get-BapEnvironmentD365App -EnvironmentId eec2c11a-a4c7-4e1d-b8ed-f62acc9c74c6 -UpdatesOnly
         
        This will query the environment for ALL available D365 Apps.
        It will compare available vs installed D365 Apps, and indicate whether an update is available of not.
        It will filter the output to only containing those who have an update available.
         
        Sample output:
        PackageId PackageName AvailableVersion InstalledVersion UpdateAvailable
        --------- ----------- ---------------- ---------------- ---------------
        ea8d3b2f-ede2-46b4-900d-ed02c81c44fd AgentProductivityToolsAnchor 9.2.24021.1005 9.2.24019.1005 True
         
    .EXAMPLE
        PS C:\> $appIds = @(Get-BapEnvironmentD365App -EnvironmentId eec2c11a-a4c7-4e1d-b8ed-f62acc9c74c6 -InstallState Installed -UpdatesOnly | Select-Object -ExpandProperty PackageId)
        PS C:\> Invoke-BapEnvironmentInstallD365App -EnvironmentId eec2c11a-a4c7-4e1d-b8ed-f62acc9c74c6 -PackageId $appIds
         
        This will query the environment for installed only D365 Apps.
        It will filter the output to only containing those who have an update available.
        It will persist the PackageIds for each D365 App, into an array.
        It will invoke the installation process using the Invoke-BapEnvironmentInstallD365App cmdlet.
         
    .EXAMPLE
        PS C:\> Get-BapEnvironmentD365App -EnvironmentId eec2c11a-a4c7-4e1d-b8ed-f62acc9c74c6 -AsExcelOutput
         
        This will query the environment for ALL available D365 Apps.
        It will compare available vs installed D365 Apps, and indicate whether an update is available of not.
        Will output all details into an Excel file, that will auto open on your machine.
         
    .NOTES
        Author: Mötz Jensen (@Splaxi)
#>

function Get-BapEnvironmentD365App {
    [CmdletBinding()]
    [OutputType('System.Object[]')]
    param (
        [parameter (mandatory = $true)]
        [string] $EnvironmentId,

        [string] $Name = "*",

        [ValidateSet("All", "Installed", "None")]
        [string] $InstallState = "All",

        [string] $GeoRegion = "Emea",

        [switch] $UpdatesOnly,

        [switch] $AsExcelOutput
    )
    
    begin {
        $tenantId = (Get-AzContext).Tenant.Id
        
        # Make sure all *BapEnvironment* cmdlets will validate that the environment exists prior running anything.
        $envObj = Get-BapEnvironment -EnvironmentId $EnvironmentId | Select-Object -First 1

        if ($null -eq $envObj) {
            $messageString = "The supplied EnvironmentId: <c='em'>$EnvironmentId</c> didn't return any matching environment details. Please verify that the EnvironmentId is correct - try running the <c='em'>Get-BapEnvironment</c> cmdlet."
            Write-PSFMessage -Level Host -Message $messageString
            Stop-PSFFunction -Message "Stopping because environment was NOT found based on the id." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', '')))
        }
        
        if (Test-PSFFunctionInterrupt) { return }

        # First we will fetch ALL available apps for the environment
        $tokenPowerApi = Get-AzAccessToken -ResourceUrl "https://api.powerplatform.com/"
        $headersPowerApi = @{
            "Authorization" = "Bearer $($tokenPowerApi.Token)"
        }
        
        $appsAvailable = Invoke-RestMethod -Method Get -Uri "https://api.powerplatform.com/appmanagement/environments/$EnvironmentId/applicationPackages?api-version=2022-03-01-preview" -Headers $headersPowerApi | Select-Object -ExpandProperty Value

        # Next we will fetch current installed apps and their details, for the environment
        $uriSourceEncoded = [System.Web.HttpUtility]::UrlEncode($envObj.LinkedMetaPpacEnvUri)
        $tokenAdminApi = Get-AzAccessToken -ResourceUrl "065d9450-1e87-434e-ac2f-69af271549ed"
        $headersAdminApi = @{
            "Authorization" = "Bearer $($tokenAdminApi.Token)"
        }

        $appsEnvironment = Invoke-RestMethod -Method Get -Uri "https://api.admin.powerplatform.microsoft.com/api/AppManagement/InstancePackages/instanceId/$tenantId`?instanceUrl=$uriSourceEncoded`&geoType=$GeoRegion" -Headers $headersAdminApi
    }
    
    process {
        if (Test-PSFFunctionInterrupt) { return }

        $resCol = @(
            foreach ($appObj in $($appsAvailable | Sort-Object -Property ApplicationName)) {
                if ((-not ($appObj.ApplicationName -like $Name -or $appObj.ApplicationName -eq $Name)) -and (-not ($appObj.UniqueName -like $Name -or $appObj.UniqueName -eq $Name))) { continue }
                if ($InstallState -ne "All" -and $appObj.state -ne $InstallState) { continue }
            
                $appObj | Add-Member -MemberType NoteProperty -Name CurrentVersion -Value "N/A"

                $currentApp = $appsEnvironment | Where-Object ApplicationId -eq $appObj.ApplicationId | Select-Object -First 1
                if ($currentApp) {
                    $appObj.CurrentVersion = $currentApp.Version
                
                    $appObj | Add-Member -MemberType NoteProperty -Name IsLatest -Value $($appObj.CurrentVersion -eq $appObj.Version)
                    $appObj | Add-Member -MemberType NoteProperty -Name UpdateAvail -Value $(-not ($appObj.CurrentVersion -eq $appObj.Version))
                }
            
                $appObj | Select-PSFObject -TypeName "D365Bap.Tools.Package" -Property "Id as PackageId",
                "UniqueName as PackageName",
                "Version as AvailableVersion",
                "CurrentVersion as InstalledVersion",
                "UpdateAvail as UpdateAvailable",
                "ApplicationName as AppName",
                "state as InstallState",
                *,
                @{Name = "SupportedCountriesList"; Expression = { $_.supportedCountries -join "," } }
            }
        )

        if ($UpdatesOnly) {
            $resCol = @($resCol | Where-Object IsLatest -eq $false)
        }

        if ($AsExcelOutput) {
            $resCol | Export-Excel -NoNumberConversion Version, AvailableVersion, InstalledVersion, crmMinversion, crmMaxVersion, Version
            return
        }

        $resCol
    }
    
    end {
        
    }
}


<#
    .SYNOPSIS
        Get users from environment
         
    .DESCRIPTION
        Enables the user to fetch all users from the environment
         
        Utilizes the built-in "systemusers" OData entity
         
        Allows the user to include all users, based on those who has the ApplicationId property filled
         
    .PARAMETER EnvironmentId
        The id of the environment that you want to work against
         
        This can be obtained from the Get-BapEnvironment cmdlet
         
    .PARAMETER IncludeAppIds
        Instruct the cmdlet to include all users that are available from the "systemusers" OData Entity
         
        Simply includes those who has the ApplicationId property filled
         
    .PARAMETER AsExcelOutput
        Instruct the cmdlet to output all details directly to an Excel file
         
        This makes it easier to deep dive into all the details returned from the API, and makes it possible for the user to persist the current state
         
    .EXAMPLE
        PS C:\> Get-BapEnvironmentUser -EnvironmentId eec2c11a-a4c7-4e1d-b8ed-f62acc9c74c6
         
        This will fetch all oridinary users from the environment.
         
        Sample output:
        Email Name AppId Systemuserid
        ----- ---- ----- ------------
        SYSTEM 5d2ff978-a74c-4ba4-8cc2-b4c5a23994f7
        INTEGRATION baabe592-2860-4d1a-9365-e95317372498
        aba@temp.com Austin Baker f85bcd69-ef72-45bd-a338-62670a8cef2a
        ade@temp.com Alex Denver 39309a5c-7676-4c8a-b702-719fb92c5151
         
    .EXAMPLE
        PS C:\> Get-BapEnvironmentUser -EnvironmentId eec2c11a-a4c7-4e1d-b8ed-f62acc9c74c6
         
        This will fetch all users from the environment.
        It will include the ones with the ApplicationId property filled.
         
        Sample output:
        Email Name AppId Systemuserid
        ----- ---- ----- ------------
        SYSTEM 5d2ff978-a74c-4ba4-8cc2-b4c5a23994f7
        INTEGRATION baabe592-2860-4d1a-9365-e95317372498
        aba@temp.com Austin Baker f85bcd69-ef72-45bd-a338-62670a8cef2a
        AIBuilderProd@onmicrosoft.com AIBuilderProd, # 0a143f2d-2320-4141-… c96f82b8-320f-4c5e-ac84-1831f4dc7d5f
         
    .EXAMPLE
        PS C:\> Get-BapEnvironmentUser -EnvironmentId eec2c11a-a4c7-4e1d-b8ed-f62acc9c74c6 -AsExcelOutput
         
        This will fetch all oridinary users from the environment.
        Will output all details into an Excel file, that will auto open on your machine.
         
    .NOTES
        Author: Mötz Jensen (@Splaxi)
#>

function Get-BapEnvironmentUser {
    [CmdletBinding()]
    param (
        [parameter (mandatory = $true)]
        [string] $EnvironmentId,

        [switch] $IncludeAppIds,

        [switch] $AsExcelOutput
    )
    
    begin {
        # Make sure all *BapEnvironment* cmdlets will validate that the environment exists prior running anything.
        $envObj = Get-BapEnvironment -EnvironmentId $EnvironmentId | Select-Object -First 1

        if ($null -eq $envObj) {
            $messageString = "The supplied EnvironmentId: <c='em'>$EnvironmentId</c> didn't return any matching environment details. Please verify that the EnvironmentId is correct - try running the <c='em'>Get-BapEnvironment</c> cmdlet."
            Write-PSFMessage -Level Host -Message $messageString
            Stop-PSFFunction -Message "Stopping because environment was NOT found based on the id." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', '')))
        }
        
        if (Test-PSFFunctionInterrupt) { return }

        $baseUri = $envObj.LinkedMetaPpacEnvUri
        $tokenWebApi = Get-AzAccessToken -ResourceUrl $baseUri
        $headersWebApi = @{
            "Authorization" = "Bearer $($tokenWebApi.Token)"
        }

        $languages = @(Get-EnvironmentLanguage -BaseUri $baseUri)
    }
    
    process {
        $resUsers = Invoke-RestMethod -Method Get -Uri $($baseUri + '/api/data/v9.2/systemusers?$select=fullname,internalemailaddress,applicationid&$expand=user_settings($select=uilanguageid)') -Headers $headersWebApi

        $resCol = @(
            foreach ($usrObj in  $($resUsers.value | Sort-Object -Property internalemailaddress)) {
                
                $usrObj | Add-Member -MemberType NoteProperty -Name "lang" -Value $($languages | Where-Object { ($_.localeid -eq $usrObj.user_settings[0].uilanguageid) -or ($_.BaseLocaleId -eq $usrObj.user_settings[0].uilanguageid) } | Select-Object -First 1 -ExpandProperty code)
                $usrObj | Select-PSFObject -TypeName "D365Bap.Tools.User" -ExcludeProperty "@odata.etag" -Property "internalemailaddress as Email",
                "fullname as Name",
                "applicationid as AppId",
                "lang as Language",
                *
            }
        )

        if (-not $IncludeAppIds) {
            $resCol = $resCol | Where-Object applicationid -eq $null
        }

        if ($AsExcelOutput) {
            $resCol | Export-Excel
            return
        }

        $resCol
    }
    
    end {
        
    }
}


<#
    .SYNOPSIS
        Invoke the installation of a D365 App in a given environment
         
    .DESCRIPTION
        This enables the invocation of the installation process against the PowerPlatform API (https://api.powerplatform.com)
         
        The cmdlet will keep requesting the status of all invoked installations, until they all have a NON "Running" state
         
        It will request this status every 60 seconds
         
    .PARAMETER EnvironmentId
        The id of the environment that you want to work against
         
        This can be obtained from the Get-BapEnvironment cmdlet
         
    .PARAMETER PackageId
        The id of the package(s) that you want to have Installed
         
        It supports id of current packages, with updates available and new D365 apps
         
        It support an array as input, so it can invoke multiple D365 App installations
         
    .EXAMPLE
        PS C:\> Invoke-BapEnvironmentInstallD365App -EnvironmentId eec2c11a-a4c7-4e1d-b8ed-f62acc9c74c6 -PackageId 'be69fc64-7393-4c3c-8908-2a1c2e53aef9','6defa8de-87f9-4478-8f9a-a7d685394e24'
         
        This will install the 2 x D365 Apps, based on the Ids supplied.
        It will run the cmdlet and have it get the status of the installation progress until all D365 Apps have been fully installed.
         
        Sample output (Install initialized):
        status createdDateTime lastActionDateTime error statusMessage operationId
        ------ --------------- ------------------ ----- ------------- -----------
        Running 02/03/2024 13.42.07 02/03/2024 13.42.16 5c80df7f-d89e-42bd-abeb-98e577ae49f4
        Running 02/03/2024 13.42.09 02/03/2024 13.42.12 6885e0f4-639f-4ebc-b21e-49ce5d5e920d
         
        Sample output (Partly succeeded installation):
        status createdDateTime lastActionDateTime error statusMessage operationId
        ------ --------------- ------------------ ----- ------------- -----------
        Succeeded 02/03/2024 13.42.07 02/03/2024 13.44.48 5c80df7f-d89e-42bd-abeb-98e577ae49f4
        Running 02/03/2024 13.42.09 02/03/2024 13.45.55 6885e0f4-639f-4ebc-b21e-49ce5d5e920d
         
        Sample output (Completely succeeded installation):
        status createdDateTime lastActionDateTime error statusMessage operationId
        ------ --------------- ------------------ ----- ------------- -----------
        Succeeded 02/03/2024 13.42.07 02/03/2024 13.44.48 5c80df7f-d89e-42bd-abeb-98e577ae49f4
        Succeeded 02/03/2024 13.42.09 02/03/2024 13.48.26 6885e0f4-639f-4ebc-b21e-49ce5d5e920d
         
    .EXAMPLE
        PS C:\> $appIds = @(Get-BapEnvironmentD365App -EnvironmentId eec2c11a-a4c7-4e1d-b8ed-f62acc9c74c6 -InstallState Installed -UpdatesOnly | Select-Object -ExpandProperty PackageId)
        PS C:\> Invoke-BapEnvironmentInstallD365App -EnvironmentId eec2c11a-a4c7-4e1d-b8ed-f62acc9c74c6 -PackageId $appIds
         
        This will find all D365 Apps that has a pending update available.
        It will gather the Ids into an array.
        It will run the cmdlet and have it get the status of the installation progress until all D365 Apps have been fully installed.
         
        Sample output (Install initialized):
        status createdDateTime lastActionDateTime error statusMessage operationId
        ------ --------------- ------------------ ----- ------------- -----------
        Running 02/03/2024 13.42.07 02/03/2024 13.42.16 5c80df7f-d89e-42bd-abeb-98e577ae49f4
        Running 02/03/2024 13.42.09 02/03/2024 13.42.12 6885e0f4-639f-4ebc-b21e-49ce5d5e920d
         
        Sample output (Partly succeeded installation):
        status createdDateTime lastActionDateTime error statusMessage operationId
        ------ --------------- ------------------ ----- ------------- -----------
        Succeeded 02/03/2024 13.42.07 02/03/2024 13.44.48 5c80df7f-d89e-42bd-abeb-98e577ae49f4
        Running 02/03/2024 13.42.09 02/03/2024 13.45.55 6885e0f4-639f-4ebc-b21e-49ce5d5e920d
         
        Sample output (Completely succeeded installation):
        status createdDateTime lastActionDateTime error statusMessage operationId
        ------ --------------- ------------------ ----- ------------- -----------
        Succeeded 02/03/2024 13.42.07 02/03/2024 13.44.48 5c80df7f-d89e-42bd-abeb-98e577ae49f4
        Succeeded 02/03/2024 13.42.09 02/03/2024 13.48.26 6885e0f4-639f-4ebc-b21e-49ce5d5e920d
         
    .NOTES
        Author: Mötz Jensen (@Splaxi)
#>

function Invoke-BapEnvironmentInstallD365App {
    [CmdletBinding()]
    param (
        [parameter (mandatory = $true)]
        [string] $EnvironmentId,

        [parameter (mandatory = $true)]
        [string[]] $PackageId
    )
    
    begin {
        # Make sure all *BapEnvironment* cmdlets will validate that the environment exists prior running anything.
        $envObj = Get-BapEnvironment -EnvironmentId $EnvironmentId | Select-Object -First 1

        if ($null -eq $envObj) {
            $messageString = "The supplied EnvironmentId: <c='em'>$EnvironmentId</c> didn't return any matching environment details. Please verify that the EnvironmentId is correct - try running the <c='em'>Get-BapEnvironment</c> cmdlet."
            Write-PSFMessage -Level Host -Message $messageString
            Stop-PSFFunction -Message "Stopping because environment found based on the id." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', '')))
        }
        
        if (Test-PSFFunctionInterrupt) { return }

        # First we will fetch ALL available apps for the environment
        $tokenPowerApi = Get-AzAccessToken -ResourceUrl "https://api.powerplatform.com/"
        $headersPowerApi = @{
            "Authorization" = "Bearer $($tokenPowerApi.Token)"
        }
        
        $appsAvailable = Get-BapEnvironmentD365App -EnvironmentId $EnvironmentId
    }
    
    process {
        if (Test-PSFFunctionInterrupt) { return }
        
        [System.Collections.Generic.List[System.Object]] $arrInstallStarted = @()
        [System.Collections.Generic.List[System.Object]] $arrStatus = @()

        $headersPowerApi."Content-Type" = "application/json;charset=utf-8"

        foreach ($pgkId in $PackageId) {
            $appToBeInstalled = $appsAvailable | Where-Object Id -eq $pgkId | Select-Object -First 1

            if ($null -eq $appToBeInstalled) {
                $messageString = "The combination of the supplied EnvironmentId: <c='em'>$EnvironmentId</c> and PackageId: <c='em'>$PackageId</c> didn't return any matching D365App. Please verify that the EnvironmentId & PackageId is correct - try running the <c='em'>Get-BapEnvironmentD365App</c> cmdlet."
                Write-PSFMessage -Level Host -Message $messageString
                Stop-PSFFunction -Message "Stopping because environment and d365app combination was NOT found based on the supplied parameters." -Exception $([System.Exception]::new($($messageString -replace '<[^>]+>', '')))
            }

            $body = $appToBeInstalled | ConvertTo-Json
            $resIntall = Invoke-RestMethod -Method Post -Uri "https://api.powerplatform.com/appmanagement/environments/$EnvironmentId/applicationPackages/$($appToBeInstalled.uniqueName)/install?api-version=2022-03-01-preview" -Headers $headersPowerApi -Body $body

            $arrInstallStarted.Add($resIntall)
        }

        do {
            $tokenPowerApi = Get-AzAccessToken -ResourceUrl "https://api.powerplatform.com/"
            $headersPowerApi = @{
                "Authorization" = "Bearer $($tokenPowerApi.Token)"
            }

            Start-Sleep -Seconds 60
            # Write-PSFMessage -Level Host -Message "Checking for running operations"

            $arrStatus = @()

            foreach ($operation in $arrInstallStarted) {
                $resInstallOperation = Invoke-RestMethod -Method Get -Uri "https://api.powerplatform.com/appmanagement/environments/$EnvironmentId/operations/$($operation.lastOperation.operationId)?api-version=2022-03-01-preview" -Headers $headersPowerApi
                $arrStatus.Add($resInstallOperation)
            }

            $arrStatus | Format-Table
        } while ("Running" -in $arrStatus.status)
    }
    
    end {
        
    }
}

<#
This is an example configuration file
 
By default, it is enough to have a single one of them,
however if you have enough configuration settings to justify having multiple copies of it,
feel totally free to split them into multiple files.
#>


<#
# Example Configuration
Set-PSFConfig -Module 'd365bap.tools' -Name 'Example.Setting' -Value 10 -Initialize -Validation 'integer' -Handler { } -Description "Example configuration setting. Your module can then use the setting using 'Get-PSFConfigValue'"
#>


Set-PSFConfig -Module 'd365bap.tools' -Name 'Import.DoDotSource' -Value $false -Initialize -Validation 'bool' -Description "Whether the module files should be dotsourced on import. By default, the files of this module are read as string value and invoked, which is faster but worse on debugging."
Set-PSFConfig -Module 'd365bap.tools' -Name 'Import.IndividualFiles' -Value $false -Initialize -Validation 'bool' -Description "Whether the module files should be imported individually. During the module build, all module code is compiled into few files, which are imported instead by default. Loading the compiled versions is faster, using the individual files is easier for debugging and testing out adjustments."

<#
Stored scriptblocks are available in [PsfValidateScript()] attributes.
This makes it easier to centrally provide the same scriptblock multiple times,
without having to maintain it in separate locations.
 
It also prevents lengthy validation scriptblocks from making your parameter block
hard to read.
 
Set-PSFScriptblock -Name 'd365bap.tools.ScriptBlockName' -Scriptblock {
     
}
#>


<#
# Example:
Register-PSFTeppScriptblock -Name "d365bap.tools.alcohol" -ScriptBlock { 'Beer','Mead','Whiskey','Wine','Vodka','Rum (3y)', 'Rum (5y)', 'Rum (7y)' }
#>


<#
# Example:
Register-PSFTeppArgumentCompleter -Command Get-Alcohol -Parameter Type -Name d365bap.tools.alcohol
#>


New-PSFLicense -Product 'd365bap.tools' -Manufacturer 'MötzJensen' -ProductVersion $script:ModuleVersion -ProductType Module -Name MIT -Version "1.0.0.0" -Date (Get-Date "2024-03-02") -Text @"
Copyright (c) 2024 MötzJensen
 
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
 
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
 
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"@

#endregion Load compiled code