Modules/businessdev.ALbuild.Marketplace/Public/New-BcMarketplaceSubmission.ps1

function New-BcMarketplaceSubmission {
    <#
    .SYNOPSIS
        Uploads app package(s) and creates a Marketplace (AppSource) submission via Partner Center.
 
    .DESCRIPTION
        Faithful port of the proven Publish-AppSourceApp / New-AppSourceSubmission flow (Partner Center
        ingestion API v1.0), so ALbuild submits exactly the way that works:
          1. read the product's Package branch + package configuration;
          2. upload the main app (Dynamics365BusinessCentralAddOnExtensionPackage) and the library apps
             (Dynamics365BusinessCentralAddOnLibraryExtensionPackage - zipped into one archive when there
             is more than one) to Azure Blob Storage via the package SAS URI, marking each Processed
             (with a 409-conflict cleanup+retry), and adding a package reference;
          3. bump the product's appVersion property from the app manifest (rejecting a downgrade);
          4. update the package configuration and create a 'preview' submission;
          5. optionally wait for validation and auto-promote to live.
        Requires the Az.Storage module (blob upload) and the auth context's PublisherId (x-ms-publisherId).
 
    .PARAMETER AuthContext
        Auth context from New-BcMarketplaceAuthContext (Partner Center scope; carries PublisherId).
 
    .PARAMETER ProductId
        The product id (with or without the 'product/' prefix).
 
    .PARAMETER AppFile
        The main .app file to submit.
 
    .PARAMETER LibraryAppFile
        Optional dependent/library .app files.
 
    .PARAMETER AutoPromote
        Promote to live after successful preview validation.
 
    .PARAMETER DoNotWait
        Return after creating the submission without waiting for validation.
 
    .PARAMETER TimeoutMinutes
        Validation wait timeout. Default 30.
 
    .OUTPUTS
        PSCustomObject: ProductId, SubmissionId, AppFile, Libraries, State, Substate, Promoted.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)] [PSCustomObject] $AuthContext,
        [Parameter(Mandatory)] [string] $ProductId,
        [Parameter(Mandatory)] [string] $AppFile,
        [string[]] $LibraryAppFile = @(),
        [switch] $AutoPromote,
        [switch] $DoNotWait,
        [int] $TimeoutMinutes = 30
    )

    # Partner Center ingestion responses are dynamic JSON whose OPTIONAL properties (value, @odata.etag,
    # @nextLink, appVersion, packageReferences, ...) are absent in many valid responses. The module runs
    # under Set-StrictMode -Version Latest, which throws on a missing property; relax it here (as the
    # proven Publish-AppSourceApp / New-AppSourceSubmission runs) so absent properties read as $null.
    Set-StrictMode -Off

    if (-not (Test-Path -LiteralPath $AppFile)) { throw "App file not found: '$AppFile'." }
    foreach ($lib in $LibraryAppFile) { if (-not (Test-Path -LiteralPath $lib)) { throw "Library file not found: '$lib'." } }
    if (-not (Get-Command New-AzStorageContext -ErrorAction SilentlyContinue)) {
        throw 'The Az.Storage module is required to upload the app package. Install it on the agent: Install-Module Az.Storage -Scope AllUsers -Force.'
    }

    $ctx = Update-BcMarketplaceAuthContext -AuthContext $AuthContext
    $cleanProductId = $ProductId -replace '^product/', ''
    $baseUrl = "https://api.partner.microsoft.com/v1.0/ingestion/products/$cleanProductId"
    $headers = @{ Authorization = "Bearer $($ctx.AccessToken)"; 'Content-Type' = 'application/json' }
    if ($ctx.PublisherId) { $headers['x-ms-publisherId'] = $ctx.PublisherId }   # required by the ingestion API

    if (-not $PSCmdlet.ShouldProcess($cleanProductId, "Create Marketplace submission for $(Split-Path $AppFile -Leaf)")) { return }

    # --- 1. Package branch + configuration ------------------------------------------------------
    Write-ALbuildLog 'Reading package configuration...'
    try {
        $branchesPackage = Invoke-RestMethod -Uri "$baseUrl/branches/getByModule(module=Package)" -Method Get -Headers $headers -ErrorAction Stop
        if ($branchesPackage.value) { $branchesPackage = $branchesPackage.value }
        if (-not $branchesPackage) { throw 'No package branches found for the product.' }
        $packageInstanceId = @($branchesPackage)[0].currentDraftInstanceID
    }
    catch { $d = Get-BcMarketplaceErrorDetail -ErrorRecord $_; throw "Failed to get package branches: $($_.Exception.Message)$(if ($d) { " | $d" })" }

    $packageConfigResponse = Invoke-RestMethod -Uri "$baseUrl/packageConfigurations/getByInstanceID(instanceID=$packageInstanceId)" -Method Get -Headers $headers -ErrorAction Stop
    $packageConfiguration = if ($packageConfigResponse.value -and @($packageConfigResponse.value).Count -gt 0) { @($packageConfigResponse.value)[0] } else { $packageConfigResponse }

    # --- 2. Library files: one archive when more than one ---------------------------------------
    $libraryArchive = ''
    $tempFolder = ''
    if ($LibraryAppFile.Count -eq 1) { $libraryArchive = $LibraryAppFile[0] }
    elseif ($LibraryAppFile.Count -gt 1) {
        $tempFolder = Join-Path ([System.IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString())
        New-Item -ItemType Directory -Path $tempFolder -Force | Out-Null
        $libraryArchive = Join-Path $tempFolder "$([System.IO.Path]::GetFileNameWithoutExtension($AppFile)).libraries.zip"
        Compress-Archive -Path $LibraryAppFile -DestinationPath $libraryArchive -CompressionLevel Fastest
    }

    try {
        $filesToProcess = @(
            [PSCustomObject]@{ File = $AppFile; ResourceType = 'Dynamics365BusinessCentralAddOnExtensionPackage' }
            [PSCustomObject]@{ File = $libraryArchive; ResourceType = 'Dynamics365BusinessCentralAddOnLibraryExtensionPackage' }
        )
        foreach ($fileInfo in $filesToProcess) {
            if (-not $fileInfo.File) { continue }
            $file = $fileInfo.File; $resourceType = $fileInfo.ResourceType
            Write-ALbuildLog "Uploading $(Split-Path -Leaf $file) ($resourceType)..."

            # Drop existing references of this type from the configuration.
            if ($packageConfiguration.packageReferences) {
                $packageConfiguration.packageReferences = @($packageConfiguration.packageReferences | Where-Object { $_.type -ne $resourceType })
            }
            else { $packageConfiguration.packageReferences = @() }

            $uploadBody = @{ resourceType = $resourceType; fileName = [System.IO.Path]::GetFileName($file) } | ConvertTo-Json
            try { $packageUpload = Invoke-RestMethod -Uri "$baseUrl/packages" -Method Post -Headers $headers -Body $uploadBody -ErrorAction Stop }
            catch {
                # 409: an unprocessed package of this type already exists - delete pending ones and retry.
                $status = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } elseif ($_.Exception.Message -match '(\d{3})') { [int]$Matches[1] } else { 0 }
                if ($status -ne 409) { throw }
                Write-ALbuildLog -Level Warning 'Package upload conflict; removing pending package(s) and retrying...'
                $existing = Invoke-RestMethod -Uri "$baseUrl/packages" -Method Get -Headers $headers -ErrorAction Stop
                $pending = @($existing.value | Where-Object { $_.resourceType -eq $resourceType -and $_.state -ne 'Processed' })
                if ($pending.Count -eq 0) { throw }
                foreach ($pkg in $pending) { try { Invoke-RestMethod -Uri "$baseUrl/packages/$($pkg.id)" -Method Delete -Headers $headers -ErrorAction Stop | Out-Null } catch { Write-ALbuildLog -Level Warning "Could not delete package $($pkg.id): $($_.Exception.Message)" } }
                Start-Sleep -Seconds 2
                $packageUpload = Invoke-RestMethod -Uri "$baseUrl/packages" -Method Post -Headers $headers -Body $uploadBody -ErrorAction Stop
            }

            # Upload the file to the returned SAS blob URI via Az.Storage.
            $uri = [System.Uri] $packageUpload.fileSasUri
            $storageAccount = $uri.DnsSafeHost.Split('.')[0]
            $container = $uri.LocalPath.TrimStart('/').Split('/')[0]
            $blobName = $uri.LocalPath.TrimStart('/').Split('/')[1]
            $storageContext = New-AzStorageContext -StorageAccountName $storageAccount -SasToken $uri.Query
            Set-AzStorageBlobContent -File $file -Container $container -Blob $blobName -Context $storageContext -Force | Out-Null
            Start-Sleep -Seconds 2

            # Mark the package Uploaded (unless already Processed) and confirm it processes.
            $packageState = Invoke-RestMethod -Uri "$baseUrl/packages/$($packageUpload.id)" -Method Get -Headers $headers -ErrorAction Stop
            if ($packageState.state -eq 'Processed') { $processed = $packageState }
            else {
                $packageState.state = 'Uploaded'
                $putHeaders = $headers.Clone()
                if ($packageState.'@odata.etag') { $putHeaders['If-Match'] = $packageState.'@odata.etag' }
                $processed = Invoke-RestMethod -Uri "$baseUrl/packages/$($packageUpload.id)" -Method Put -Headers $putHeaders -Body (($packageState | ConvertTo-BcHashTable) | ConvertTo-Json -Depth 10) -ContentType 'application/json' -ErrorAction Stop
            }
            if ($processed.state -ne 'Processed') { throw "Package '$(Split-Path -Leaf $file)' was not processed (state: $($processed.state))." }
            $packageConfiguration.packageReferences += @([PSCustomObject]@{ type = $resourceType; value = $processed.id })
            Write-ALbuildLog -Level Success "Uploaded and processed $(Split-Path -Leaf $file)."
        }
    }
    finally { if ($tempFolder -and (Test-Path -LiteralPath $tempFolder)) { Remove-Item -LiteralPath $tempFolder -Recurse -Force -ErrorAction SilentlyContinue } }

    # --- 3. Bump the product appVersion property from the app manifest ---------------------------
    $propertyInstanceId = $null
    $appVersion = (Expand-BcAppFile -Path $AppFile).Version
    Write-ALbuildLog "Setting product version to $appVersion..."
    $branchesProperty = Invoke-RestMethod -Uri "$baseUrl/branches/getByModule(module=Property)" -Method Get -Headers $headers -ErrorAction Stop
    if ($branchesProperty.value) { $branchesProperty = $branchesProperty.value }
    if (-not $branchesProperty) { throw 'No property branches found for the product.' }
    $propertyInstanceId = @($branchesProperty)[0].currentDraftInstanceID
    $propertiesResponse = Invoke-RestMethod -Uri "$baseUrl/properties/getByInstanceID(instanceID=$propertyInstanceId)" -Method Get -Headers $headers -ErrorAction Stop
    # Normalise via ConvertTo-BcResourceList (two-statement @() assignment): on Windows PowerShell 5.1 an
    # inline `$x = if (...) { @(...) } else { @(...) }` unwraps a single-element array to a scalar, which
    # made `.Count` blank and always threw "Unable to locate the product property resource (found )".
    $properties = @(ConvertTo-BcResourceList $propertiesResponse)
    if ($properties.Count -ne 1) { throw "Unable to locate the product property resource (found $($properties.Count))." }
    $property = $properties[0]
    $prev = [version]'0.0.0.0'
    if ($property.appVersion -and [version]::TryParse("$($property.appVersion)", [ref] $prev) -and $prev -gt $appVersion) {
        throw "The new version ($appVersion) is lower than the version already in Partner Center ($prev)."
    }
    $property.appVersion = $appVersion.ToString()
    $propHeaders = $headers.Clone()
    if ($property.'@odata.etag') { $propHeaders['If-Match'] = $property.'@odata.etag' }
    Invoke-RestMethod -Uri "$baseUrl/properties/$($property.id)" -Method Put -Headers $propHeaders -Body (($property | ConvertTo-BcHashTable -Recurse) | ConvertTo-Json -Depth 10) -ContentType 'application/json' -ErrorAction Stop | Out-Null

    # --- 4. Update package configuration, then create the submission ----------------------------
    $cfgHeaders = $headers.Clone()
    if ($packageConfiguration.'@odata.etag') { $cfgHeaders['If-Match'] = $packageConfiguration.'@odata.etag' }
    Invoke-RestMethod -Uri "$baseUrl/packageConfigurations/$($packageConfiguration.id)" -Method Put -Headers $cfgHeaders -Body (($packageConfiguration | ConvertTo-BcHashTable -Recurse) | ConvertTo-Json -Depth 10) -ContentType 'application/json' -ErrorAction Stop | Out-Null

    # Delete an existing in-progress submission so a fresh one can be created.
    try {
        $inProgress = @((Invoke-RestMethod -Uri "$baseUrl/submissions" -Method Get -Headers $headers -ErrorAction Stop).value | Where-Object { $_.state -eq 'InProgress' })[0]
        if ($inProgress) { Invoke-RestMethod -Uri "$baseUrl/submissions/$($inProgress.id)" -Method Delete -Headers $headers -ErrorAction Stop | Out-Null; Start-Sleep -Seconds 2 }
    }
    catch { Write-ALbuildLog -Level Verbose "Could not check/delete an in-progress submission: $($_.Exception.Message)" }

    $resources = @(@{ type = 'Package'; value = $packageInstanceId })
    if ($propertyInstanceId) { $resources += @{ type = 'Property'; value = $propertyInstanceId } }
    $submissionBody = @{ resourceType = 'SubmissionCreationRequest'; targets = @(@{ type = 'Scope'; value = 'preview' }); resources = $resources } | ConvertTo-Json -Depth 10
    try { $submission = Invoke-RestMethod -Uri "$baseUrl/submissions" -Method Post -Headers $headers -Body $submissionBody -ErrorAction Stop }
    catch { $d = Get-BcMarketplaceErrorDetail -ErrorRecord $_; throw "Failed to create submission: $($_.Exception.Message)$(if ($d) { " | $d" })" }
    $submissionId = $submission.id
    Write-ALbuildLog -Level Success "Created submission $submissionId."

    # --- 5. Wait for validation, optionally promote ---------------------------------------------
    if (-not $DoNotWait) {
        Write-ALbuildLog "Waiting up to $TimeoutMinutes minute(s) for validation..."
        $start = Get-Date; $complete = $false
        while (-not $complete -and ((Get-Date) - $start).TotalMinutes -lt $TimeoutMinutes) {
            Start-Sleep -Seconds 30
            $ctx = Update-BcMarketplaceAuthContext -AuthContext $ctx
            $headers['Authorization'] = "Bearer $($ctx.AccessToken)"
            try {
                $s = Invoke-RestMethod -Uri "$baseUrl/submissions/$submissionId" -Method Get -Headers $headers -ErrorAction Stop
                Write-ALbuildLog -Level Verbose " submission state: $($s.state)/$($s.substate)"
                if ($s.state -eq 'Published' -and $s.substate -eq 'ReadyToPublish') { $complete = $true; Write-ALbuildLog -Level Success 'Validation complete.' }
                elseif ($s.state -eq 'Failed') { throw "Submission validation failed: $($s.substate)." }
            }
            catch { if ("$($_.Exception.Message)" -like '*validation failed*') { throw }; Write-ALbuildLog -Level Verbose " (status check retry: $($_.Exception.Message))" }
        }
        if (-not $complete) { Write-ALbuildLog -Level Warning "Validation did not complete within $TimeoutMinutes minute(s)." }

        if ($AutoPromote -and $complete) {
            Write-ALbuildLog 'Promoting submission to live...'
            try { Invoke-RestMethod -Uri "$baseUrl/submissions/$submissionId/promote" -Method Post -Headers $headers -Body '{}' -ErrorAction Stop | Out-Null; Write-ALbuildLog -Level Success 'Promoted to live.' }
            catch { Write-ALbuildLog -Level Warning "Failed to promote: $($_.Exception.Message)" }
        }
    }

    $final = Invoke-RestMethod -Uri "$baseUrl/submissions/$submissionId" -Method Get -Headers $headers -ErrorAction SilentlyContinue
    return [PSCustomObject]@{
        ProductId    = $cleanProductId
        SubmissionId = $submissionId
        AppFile      = $AppFile
        Libraries    = $LibraryAppFile
        State        = $final.state
        Substate     = $final.substate
        Promoted     = ($AutoPromote.IsPresent -and -not $DoNotWait)
    }
}