InvokeHubspot.psm1

# InvokeHubspot
# Private App Bearer token support for HubSpot CRM Deals API.
# Supports local sessions via Windows Credential Manager and Azure Automation via Get-AutomationPSCredential.

# Version 0.1.3 06.08.2026 by Klaus Kupferschmid (tempero.it GmbH & hhpberlin GmbH)

#Requires -Modules @{ ModuleName = 'BetterCredentials'; ModuleVersion = '4.5' }

$script:HubspotSettings = [ordered]@{
    ServiceUserName = 'Hubspot_Deals'
    BaseUri = 'https://api.hubapi.com'
    DealsPath = 'crm/v3/objects/deals'
    TokenTarget = 'Hubspot_Deals_BearerToken'
    DefaultPageSize = 100
    DefaultRetryCount = 2
    DefaultThrottleDelaySeconds = 0.2
}

<#
.SYNOPSIS
Shows the active InvokeHubspot module configuration.
 
.DESCRIPTION
Returns the current runtime settings such as API base URI, default paging size,
retry behavior, and credential target name.
 
.EXAMPLE
Get-HubspotConfiguration
 
.EXAMPLE
Get-HubspotConfiguration | Format-List *
 
.EXAMPLE
# Troubleshooting: Aktive Runtime-Werte pruefen, wenn Paging/Retry unerwartet ist.
Get-HubspotConfiguration | Select-Object DefaultPageSize, DefaultRetryCount, DefaultThrottleDelaySeconds
#>

function Get-HubspotConfiguration {
    [CmdletBinding()]
    param ()

    return [pscustomobject]@{
        ServiceUserName = $script:HubspotSettings.ServiceUserName
        BaseUri = $script:HubspotSettings.BaseUri
        DealsPath = $script:HubspotSettings.DealsPath
        TokenTarget = $script:HubspotSettings.TokenTarget
        DefaultPageSize = $script:HubspotSettings.DefaultPageSize
        DefaultRetryCount = $script:HubspotSettings.DefaultRetryCount
        DefaultThrottleDelaySeconds = $script:HubspotSettings.DefaultThrottleDelaySeconds
    }
}

<#
.SYNOPSIS
Updates InvokeHubspot module configuration values.
 
.DESCRIPTION
Changes selected runtime settings for the current session, for example default page size,
base URI, retry count, and credential target name.
 
.EXAMPLE
Set-HubspotConfiguration -DefaultPageSize 200
 
.EXAMPLE
Set-HubspotConfiguration -DefaultRetryCount 3 -DefaultThrottleDelaySeconds 0.5
 
.EXAMPLE
Set-HubspotConfiguration -BaseUri 'https://api.hubapi.com' -DealsPath 'crm/v3/objects/deals'
 
.EXAMPLE
Set-HubspotConfiguration -ServiceUserName 'Hubspot_Deals' -TokenTarget 'Hubspot_Deals_BearerToken'
 
.EXAMPLE
# Troubleshooting: Nach Experimenten auf stabile Defaults zuruecksetzen.
Set-HubspotConfiguration -DefaultPageSize 100 -DefaultRetryCount 2 -DefaultThrottleDelaySeconds 0.2
#>

function Set-HubspotConfiguration {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false)][string]$ServiceUserName,
        [Parameter(Mandatory = $false)][string]$BaseUri,
        [Parameter(Mandatory = $false)][string]$DealsPath,
        [Parameter(Mandatory = $false)][string]$TokenTarget,
        [Parameter(Mandatory = $false)][int]$DefaultPageSize,
        [Parameter(Mandatory = $false)][int]$DefaultRetryCount,
        [Parameter(Mandatory = $false)][double]$DefaultThrottleDelaySeconds
    )

    if ($PSBoundParameters.ContainsKey('ServiceUserName')) {
        $script:HubspotSettings.ServiceUserName = $ServiceUserName
    }

    if ($PSBoundParameters.ContainsKey('BaseUri')) {
        $script:HubspotSettings.BaseUri = $BaseUri.TrimEnd('/')
    }

    if ($PSBoundParameters.ContainsKey('DealsPath')) {
        $script:HubspotSettings.DealsPath = $DealsPath.Trim('/ ')
    }

    if ($PSBoundParameters.ContainsKey('TokenTarget')) {
        $script:HubspotSettings.TokenTarget = $TokenTarget
    }

    if ($PSBoundParameters.ContainsKey('DefaultPageSize')) {
        $script:HubspotSettings.DefaultPageSize = [Math]::Max(1, [Math]::Min(200, $DefaultPageSize))
    }

    if ($PSBoundParameters.ContainsKey('DefaultRetryCount')) {
        $script:HubspotSettings.DefaultRetryCount = [Math]::Max(0, $DefaultRetryCount)
    }

    if ($PSBoundParameters.ContainsKey('DefaultThrottleDelaySeconds')) {
        $script:HubspotSettings.DefaultThrottleDelaySeconds = [Math]::Max(0.0, $DefaultThrottleDelaySeconds)
    }

    return Get-HubspotConfiguration
}

function Initialize-AutomationEnvironment {
    if ($script:automationEnvironmentInitialized) {
        return
    }

    $script:automationEnvironmentInitialized = $true
    $script:env_runbook = $false

    try {
        if ($PSPrivateMetadata.JobId) {
            $script:env_runbook = $true
        }
    }
    catch {
        $script:env_runbook = $false
    }
}

function Convert-SecureStringToPlainText {
    param (
        [Parameter(Mandatory = $false)]
        [Security.SecureString] $SecureString
    )

    if ($null -eq $SecureString) {
        return ''
    }

    $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString)
    try {
        return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
    }
    finally {
        if ($bstr -ne [IntPtr]::Zero) {
            [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
        }
    }
}

function Get-StoredCredentialSafe {
    param (
        [Parameter(Mandatory = $true)]
        [string] $Target
    )

    $globalErrorCountBefore = $global:Error.Count

    try {
        return Find-Credential -Filter $Target -ErrorAction Stop | Select-Object -First 1
    }
    catch {
        $message = $PSItem.Exception.Message
        $isExpectedNotFound = (
            $message -match 'Element.*(not found|nicht gefunden)' -or
            $message -match '(Credential|Element).*(not found|nicht gefunden)'
        )

        if ($isExpectedNotFound) {
            while ($global:Error.Count -gt $globalErrorCountBefore) {
                $global:Error.RemoveAt(0)
            }
            return $null
        }

        Write-Warning "Unerwarteter Fehler bei Find-Credential fuer Target '$Target': $message"
        return $null
    }
}

function Test-HubspotAuthenticationFailure {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]$ErrorRecord,
        [Parameter(Mandatory = $false)][int]$StatusCode
    )

    $errorMessage = [string]$ErrorRecord.Exception.Message
    $errorDetailsMessage = [string]$ErrorRecord.ErrorDetails.Message

    $hasInvalidAuthIndicator = (
        $errorMessage -match 'INVALID_AUTHENTICATION' -or
        $errorMessage -match 'Authentication credentials not found' -or
        $errorMessage -match 'OAuth 2\.0 authentication' -or
        $errorDetailsMessage -match 'INVALID_AUTHENTICATION' -or
        $errorDetailsMessage -match 'Authentication credentials not found' -or
        $errorDetailsMessage -match 'OAuth 2\.0 authentication'
    )

    if ($hasInvalidAuthIndicator) {
        return $true
    }

    if ($StatusCode -in @(401, 403)) {
        return $true
    }

    return $false
}

function Test-HubspotMissingScopeFailure {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]$ErrorRecord,
        [Parameter(Mandatory = $false)][string]$ExpectedScope,
        [Parameter(Mandatory = $false)][string]$ObjectHint
    )

    $statusCode = $null
    if ($ErrorRecord.Exception.Response -and $ErrorRecord.Exception.Response.StatusCode) {
        $statusCode = [int]$ErrorRecord.Exception.Response.StatusCode
    }

    $errorMessage = [string]$ErrorRecord.Exception.Message
    $errorDetailsMessage = [string]$ErrorRecord.ErrorDetails.Message
    $combinedMessage = "$errorMessage $errorDetailsMessage"

    $hasScopeIndicator = (
        $combinedMessage -match 'insufficient scopes|missing scopes|required scopes|forbidden|insufficient permissions|permission denied'
    )

    if (-not $hasScopeIndicator -and $statusCode -ne 403) {
        return $false
    }

    if (-not [string]::IsNullOrWhiteSpace($ExpectedScope) -and $combinedMessage -match [Regex]::Escape($ExpectedScope)) {
        return $true
    }

    if (-not [string]::IsNullOrWhiteSpace($ObjectHint) -and $combinedMessage -match [Regex]::Escape($ObjectHint)) {
        return $true
    }

    if ($statusCode -eq 403) {
        return $true
    }

    return $false
}

function Write-HubspotMissingScopeWarning {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)][string]$Scope,
        [Parameter(Mandatory = $true)][string]$PropertyName,
        [Parameter(Mandatory = $true)][string]$ObjectLabel
    )

    if (-not $script:HubspotMissingScopeWarningsShown) {
        $script:HubspotMissingScopeWarningsShown = @{}
    }

    $warningKey = "$Scope|$PropertyName"
    if ($script:HubspotMissingScopeWarningsShown.ContainsKey($warningKey)) {
        return
    }

    Write-Warning "DisplayValue fuer Property '$PropertyName' kann nicht aufgeloest werden. Fuer $ObjectLabel wird Scope $Scope benoetigt. Bis dahin bleibt DisplayValue die interne ID."
    $script:HubspotMissingScopeWarningsShown[$warningKey] = $true
}

function Invoke-HubspotRequest {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)][string]$ResourcePath,
        [Parameter(Mandatory = $false)][string]$Method = 'GET',
        [Parameter(Mandatory = $false)][hashtable]$Query,
        [Parameter(Mandatory = $false)]$Body,
        [Parameter(Mandatory = $false)][string]$AccessToken,
        [Parameter(Mandatory = $false)][int]$RetryCount = $script:HubspotSettings.DefaultRetryCount,
        [Parameter(Mandatory = $false)][double]$ThrottleDelaySeconds = $script:HubspotSettings.DefaultThrottleDelaySeconds
    )

    $token = Get-HubspotBearerToken -AccessToken $AccessToken
    $authRetryTriggered = $false
    $headers = @{
        Accept = 'application/json'
        Authorization = "Bearer $token"
    }

    $resourceUri = $script:HubspotSettings.BaseUri.TrimEnd('/')
    if ($ResourcePath) {
        $resourceUri = "$resourceUri/$($ResourcePath.TrimStart('/'))"
    }

    $uriBuilder = [System.UriBuilder]::new($resourceUri)
    if ($Query -and $Query.Count -gt 0) {
        $queryCollection = [System.Web.HttpUtility]::ParseQueryString([string]::Empty)
        foreach ($key in $Query.Keys) {
            $value = $Query[$key]
            if ($null -eq $value) {
                continue
            }

            if ($value -is [System.Collections.IEnumerable] -and -not ($value -is [string])) {
                foreach ($item in $value) {
                    $queryCollection.Add($key, [string]$item)
                }
                continue
            }

            $queryCollection[$key] = [string]$value
        }

        $queryString = $queryCollection.ToString()
        if (-not [string]::IsNullOrWhiteSpace($queryString)) {
            $uriBuilder.Query = $queryString
        }
    }

    $validatedUri = [uri]::new($uriBuilder.Uri.AbsoluteUri)

    $invokeParams = @{
        Uri = $validatedUri
        Method = $Method
        Headers = $headers
        ErrorAction = 'Stop'
    }

    if ($Body) {
        $invokeParams.ContentType = 'application/json'
        $invokeParams.Body = $Body | ConvertTo-Json -Depth 20
    }

    for ($attempt = 0; $attempt -le $RetryCount; $attempt++) {
        try {
            if ($ThrottleDelaySeconds -gt 0) {
                Start-Sleep -Milliseconds ([int]($ThrottleDelaySeconds * 1000))
            }

            return Invoke-RestMethod @invokeParams
        }
        catch {
            $statusCode = $null
            if ($_.Exception.Response -and $_.Exception.Response.StatusCode) {
                $statusCode = [int]$_.Exception.Response.StatusCode
            }

            $isAuthenticationFailure = Test-HubspotAuthenticationFailure -ErrorRecord $_ -StatusCode $statusCode
            if ($isAuthenticationFailure -and [string]::IsNullOrWhiteSpace($AccessToken) -and -not $authRetryTriggered) {
                try {
                    $token = Get-HubspotBearerToken -ForcePrompt
                }
                catch {
                    throw
                }

                $headers.Authorization = "Bearer $token"
                $authRetryTriggered = $true
                continue
            }

            if ($attempt -lt $RetryCount -and ($statusCode -in @(429, 500, 502, 503, 504))) {
                $backoffSeconds = [Math]::Min(20, [Math]::Pow(2, $attempt + 1))
                Start-Sleep -Seconds $backoffSeconds
                continue
            }

            throw
        }
    }
}

function Get-HubspotPropertyDefinitions {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false)][string]$ObjectType = 'deals',
        [Parameter(Mandatory = $false)][string]$AccessToken
    )

    if (-not $script:HubspotPropertyDefinitionsByObjectType) {
        $script:HubspotPropertyDefinitionsByObjectType = @{}
    }

    if ($script:HubspotPropertyDefinitionsByObjectType.ContainsKey($ObjectType)) {
        return $script:HubspotPropertyDefinitionsByObjectType[$ObjectType]
    }

    $response = Invoke-HubspotRequest -ResourcePath "crm/v3/properties/$ObjectType" -AccessToken $AccessToken
    $definitionMap = @{}

    foreach ($definition in @($response.results)) {
        if ($definition -and $definition.name) {
            $definitionMap[$definition.name] = $definition
        }
    }

    $script:HubspotPropertyDefinitionsByObjectType[$ObjectType] = $definitionMap
    return $definitionMap
}

function Get-HubspotDealStageDefinition {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false)][string]$PipelineId,
        [Parameter(Mandatory = $false)][string]$StageId,
        [Parameter(Mandatory = $false)][string]$AccessToken
    )

    if ([string]::IsNullOrWhiteSpace($StageId)) {
        return $null
    }

    if (-not $script:HubspotDealPipelineDefinitionsById) {
        $script:HubspotDealPipelineDefinitionsById = @{ }
    }

    if (-not $script:HubspotDealPipelineDefinitionsLoaded) {
        $response = Invoke-HubspotRequest -ResourcePath 'crm/v3/pipelines/deals' -AccessToken $AccessToken

        foreach ($pipeline in @($response.results)) {
            if (-not $pipeline -or -not $pipeline.id) {
                continue
            }

            $stageMap = @{}
            foreach ($stage in @($pipeline.stages)) {
                if ($stage -and $stage.id) {
                    $stageMap[[string]$stage.id] = [pscustomobject]@{
                        Id = [string]$stage.id
                        Label = [string]$stage.label
                        PipelineId = [string]$pipeline.id
                        PipelineLabel = [string]$pipeline.label
                    }
                }
            }

            $script:HubspotDealPipelineDefinitionsById[[string]$pipeline.id] = [pscustomobject]@{
                Id = [string]$pipeline.id
                Label = [string]$pipeline.label
                Stages = $stageMap
            }
        }

        $script:HubspotDealPipelineDefinitionsLoaded = $true
    }

    if (-not [string]::IsNullOrWhiteSpace($PipelineId) -and $script:HubspotDealPipelineDefinitionsById.ContainsKey($PipelineId)) {
        $pipelineDefinition = $script:HubspotDealPipelineDefinitionsById[$PipelineId]
        if ($pipelineDefinition.Stages.ContainsKey($StageId)) {
            return $pipelineDefinition.Stages[$StageId]
        }
    }

    foreach ($pipelineDefinition in @($script:HubspotDealPipelineDefinitionsById.Values)) {
        if ($pipelineDefinition.Stages.ContainsKey($StageId)) {
            return $pipelineDefinition.Stages[$StageId]
        }
    }

    return $null
}

function Get-HubspotOwnerDisplayValue {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false)][string]$OwnerId,
        [Parameter(Mandatory = $false)][string]$AccessToken
    )

    if ([string]::IsNullOrWhiteSpace($OwnerId)) {
        return $OwnerId
    }

    if (-not $script:HubspotOwnerDisplayById) {
        $script:HubspotOwnerDisplayById = @{}
    }

    if ($script:HubspotOwnerDisplayById.ContainsKey($OwnerId)) {
        return $script:HubspotOwnerDisplayById[$OwnerId]
    }

    $displayValue = $null
    try {
        $owner = Invoke-HubspotRequest -ResourcePath "crm/v3/owners/$OwnerId" -AccessToken $AccessToken
        if ($owner) {
            $fullName = ("{0} {1}" -f ([string]$owner.firstName), ([string]$owner.lastName)).Trim()
            if (-not [string]::IsNullOrWhiteSpace($fullName)) {
                $displayValue = $fullName
            }
            elseif (-not [string]::IsNullOrWhiteSpace([string]$owner.email)) {
                $displayValue = [string]$owner.email
            }
        }
    }
    catch {
        if (Test-HubspotMissingScopeFailure -ErrorRecord $PSItem -ExpectedScope 'crm.objects.owners.read' -ObjectHint 'owners') {
            Write-HubspotMissingScopeWarning -Scope 'crm.objects.owners.read' -PropertyName 'hubspot_owner_id' -ObjectLabel 'Owner-Aufloesung'
        }

        $displayValue = $null
    }

    if ([string]::IsNullOrWhiteSpace($displayValue)) {
        $displayValue = $OwnerId
    }

    $script:HubspotOwnerDisplayById[$OwnerId] = $displayValue
    return $displayValue
}

function Get-HubspotRelatedObjectDisplayValue {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)][string]$PropertyName,
        [Parameter(Mandatory = $true)][string]$ObjectType,
        [Parameter(Mandatory = $false)][string]$ObjectId,
        [Parameter(Mandatory = $false)][string[]]$QueryProperties,
        [Parameter(Mandatory = $false)][string[]]$DisplayPropertyCandidates,
        [Parameter(Mandatory = $true)][string]$RequiredReadScope,
        [Parameter(Mandatory = $false)][string]$AccessToken
    )

    if ([string]::IsNullOrWhiteSpace($ObjectId)) {
        return $ObjectId
    }

    if (-not $script:HubspotRelatedDisplayByTypeAndId) {
        $script:HubspotRelatedDisplayByTypeAndId = @{}
    }

    if (-not $script:HubspotRelatedDisplayByTypeAndId.ContainsKey($ObjectType)) {
        $script:HubspotRelatedDisplayByTypeAndId[$ObjectType] = @{}
    }

    $objectCache = $script:HubspotRelatedDisplayByTypeAndId[$ObjectType]
    if ($objectCache.ContainsKey($ObjectId)) {
        return $objectCache[$ObjectId]
    }

    $displayValue = $null
    try {
        $query = @{}
        if ($QueryProperties -and $QueryProperties.Count -gt 0) {
            $query.properties = $QueryProperties
        }

        $record = Invoke-HubspotRequest -ResourcePath "crm/v3/objects/$ObjectType/$ObjectId" -Query $query -AccessToken $AccessToken
        $propertyBag = $record.properties

        if ($ObjectType -eq 'contacts' -and $propertyBag) {
            $fullName = ("{0} {1}" -f ([string]$propertyBag.firstname), ([string]$propertyBag.lastname)).Trim()
            if (-not [string]::IsNullOrWhiteSpace($fullName)) {
                $displayValue = $fullName
            }
        }

        if ([string]::IsNullOrWhiteSpace($displayValue) -and $propertyBag -and $DisplayPropertyCandidates) {
            foreach ($candidateProperty in $DisplayPropertyCandidates) {
                if ($propertyBag.PSObject.Properties.Name -contains $candidateProperty) {
                    $candidateValue = [string]$propertyBag.$candidateProperty
                    if (-not [string]::IsNullOrWhiteSpace($candidateValue)) {
                        $displayValue = $candidateValue
                        break
                    }
                }
            }
        }

        if ([string]::IsNullOrWhiteSpace($displayValue)) {
            foreach ($fallbackName in @('name', 'hs_name', 'title', 'subject', 'email')) {
                if ($propertyBag -and ($propertyBag.PSObject.Properties.Name -contains $fallbackName)) {
                    $candidateValue = [string]$propertyBag.$fallbackName
                    if (-not [string]::IsNullOrWhiteSpace($candidateValue)) {
                        $displayValue = $candidateValue
                        break
                    }
                }
            }
        }
    }
    catch {
        if (Test-HubspotMissingScopeFailure -ErrorRecord $PSItem -ExpectedScope $RequiredReadScope -ObjectHint $ObjectType) {
            Write-HubspotMissingScopeWarning -Scope $RequiredReadScope -PropertyName $PropertyName -ObjectLabel "$ObjectType-Aufloesung"
        }
    }

    if ([string]::IsNullOrWhiteSpace($displayValue)) {
        $displayValue = $ObjectId
    }

    $objectCache[$ObjectId] = $displayValue
    return $displayValue
}

function Convert-HubspotDealPropertiesForWrite {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)][hashtable]$Properties,
        [Parameter(Mandatory = $false)][string]$AccessToken
    )

    $writeProperties = [ordered]@{}

    foreach ($entry in $Properties.GetEnumerator()) {
        if ([string]::IsNullOrWhiteSpace([string]$entry.Key)) {
            continue
        }

        if ($entry.Key -ieq 'dealstageId') {
            $writeProperties.dealstage = [string]$entry.Value
            continue
        }

        if ($entry.Key -ieq 'dealstageLabel') {
            continue
        }

        if ($entry.Key -ieq 'dealstage') {
            $stageValue = [string]$entry.Value
            $stageDefinition = Get-HubspotDealStageDefinition -StageId $stageValue -AccessToken $AccessToken

            if ($stageDefinition) {
                $writeProperties.dealstage = $stageDefinition.Id
                continue
            }

            $writeProperties.dealstage = $stageValue
            continue
        }

        $writeProperties[$entry.Key] = $entry.Value
    }

    return $writeProperties
}

function Convert-HubspotPropertyValue {
    param (
        [Parameter(Mandatory = $false)]$Value,
        [Parameter(Mandatory = $false)]$Definition
    )

    if ($null -eq $Value) {
        return $null
    }

    if ($Value -is [System.Collections.IDictionary]) {
        $nestedObject = [ordered]@{}
        foreach ($key in $Value.Keys) {
            $nestedObject[$key] = Convert-HubspotPropertyValue -Value $Value[$key]
        }
        return [pscustomobject]$nestedObject
    }

    if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) {
        $convertedItems = foreach ($item in $Value) {
            Convert-HubspotPropertyValue -Value $item
        }
        return @($convertedItems)
    }

    $propertyType = $null
    $fieldType = $null
    if ($Definition) {
        $propertyType = [string]$Definition.type
        $fieldType = [string]$Definition.fieldType
    }

    switch ($propertyType) {
        'bool' {
            try {
                return [System.Convert]::ToBoolean($Value)
            }
            catch {
                return $Value
            }
        }

        'number' {
            $numericValue = 0.0
            if ([double]::TryParse([string]$Value, [System.Globalization.NumberStyles]::Any, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$numericValue)) {
                return $numericValue
            }

            return $Value
        }

        'date' {
            try {
                if ([string]$Value -match '^\d+$') {
                    return [datetimeoffset]::FromUnixTimeMilliseconds([int64]$Value).UtcDateTime.Date
                }

                return [datetime]::Parse([string]$Value, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal).Date
            }
            catch {
                return $Value
            }
        }

        'datetime' {
            try {
                if ([string]$Value -match '^\d+$') {
                    return [datetimeoffset]::FromUnixTimeMilliseconds([int64]$Value).UtcDateTime
                }

                return [datetime]::Parse([string]$Value, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::RoundtripKind)
            }
            catch {
                return $Value
            }
        }

        'enumeration' {
            if ($fieldType -eq 'checkbox' -or ([string]$Value -like '*;*')) {
                $selectedValues = @(
                    [string]$Value -split ';' |
                        Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
                )

                if ($selectedValues.Count -gt 1) {
                    return $selectedValues
                }
            }

            return $Value
        }

        'json' {
            try {
                return [string]$Value | ConvertFrom-Json -Depth 20
            }
            catch {
                return $Value
            }
        }

        default {
            return $Value
        }
    }
}

function Resolve-HubspotPropertyDisplayValue {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)][string]$PropertyName,
        [Parameter(Mandatory = $false)]$Value,
        [Parameter(Mandatory = $false)]$Definition,
        [Parameter(Mandatory = $false)][string]$PipelineId,
        [Parameter(Mandatory = $false)][string]$AccessToken
    )

    if ($null -eq $Value) {
        return $null
    }

    if ($PropertyName -ieq 'dealstage') {
        $stageDefinition = Get-HubspotDealStageDefinition -PipelineId $PipelineId -StageId ([string]$Value) -AccessToken $AccessToken
        if ($stageDefinition -and -not [string]::IsNullOrWhiteSpace([string]$stageDefinition.Label)) {
            return [string]$stageDefinition.Label
        }
        return $Value
    }

    if ($PropertyName -ieq 'hubspot_owner_id') {
        return Get-HubspotOwnerDisplayValue -OwnerId ([string]$Value) -AccessToken $AccessToken
    }

    if ($PropertyName -match '^(associatedcompanyid|companyid|hs_company_id)$') {
        return Get-HubspotRelatedObjectDisplayValue -PropertyName $PropertyName -ObjectType 'companies' -ObjectId ([string]$Value -replace ';.*$') -QueryProperties @('name', 'domain') -DisplayPropertyCandidates @('name', 'domain') -RequiredReadScope 'crm.objects.companies.read' -AccessToken $AccessToken
    }

    if ($PropertyName -match '^(associatedcontactid|contactid|hs_contact_id)$') {
        return Get-HubspotRelatedObjectDisplayValue -PropertyName $PropertyName -ObjectType 'contacts' -ObjectId ([string]$Value -replace ';.*$') -QueryProperties @('firstname', 'lastname', 'email') -DisplayPropertyCandidates @('email') -RequiredReadScope 'crm.objects.contacts.read' -AccessToken $AccessToken
    }

    if ($PropertyName -match '^(associatedlineitemid|line_item_id|hs_line_item_id)$') {
        return Get-HubspotRelatedObjectDisplayValue -PropertyName $PropertyName -ObjectType 'line_items' -ObjectId ([string]$Value -replace ';.*$') -QueryProperties @('name', 'hs_name') -DisplayPropertyCandidates @('name', 'hs_name') -RequiredReadScope 'crm.objects.line_items.read' -AccessToken $AccessToken
    }

    if ($PropertyName -match '^(associatedproductid|productid|hs_product_id)$') {
        return Get-HubspotRelatedObjectDisplayValue -PropertyName $PropertyName -ObjectType 'products' -ObjectId ([string]$Value -replace ';.*$') -QueryProperties @('name', 'hs_name') -DisplayPropertyCandidates @('name', 'hs_name') -RequiredReadScope 'crm.objects.products.read' -AccessToken $AccessToken
    }

    if ($PropertyName -match '^(associatedquoteid|quoteid|hs_quote_id)$') {
        return Get-HubspotRelatedObjectDisplayValue -PropertyName $PropertyName -ObjectType 'quotes' -ObjectId ([string]$Value -replace ';.*$') -QueryProperties @('hs_title', 'hs_quote_number') -DisplayPropertyCandidates @('hs_title', 'hs_quote_number') -RequiredReadScope 'crm.objects.quotes.read' -AccessToken $AccessToken
    }

    if ($PropertyName -match '^(associatedticketid|ticketid|hs_ticket_id)$') {
        return Get-HubspotRelatedObjectDisplayValue -PropertyName $PropertyName -ObjectType 'tickets' -ObjectId ([string]$Value -replace ';.*$') -QueryProperties @('subject', 'content') -DisplayPropertyCandidates @('subject', 'content') -RequiredReadScope 'crm.objects.tickets.read' -AccessToken $AccessToken
    }

    if ($Definition -and [string]$Definition.type -eq 'enumeration' -and $Definition.options) {
        $optionLabelByValue = @{}
        foreach ($option in @($Definition.options)) {
            if ($option -and $option.value) {
                $optionLabelByValue[[string]$option.value] = [string]$option.label
            }
        }

        if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) {
            $labels = @()
            foreach ($entry in $Value) {
                $entryString = [string]$entry
                if ($optionLabelByValue.ContainsKey($entryString)) {
                    $labels += $optionLabelByValue[$entryString]
                }
                else {
                    $labels += $entry
                }
            }
            return $labels
        }

        $valueString = [string]$Value
        if ($optionLabelByValue.ContainsKey($valueString)) {
            return $optionLabelByValue[$valueString]
        }
    }

    return $Value
}

function Convert-HubspotRecordToObject {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]$Record,
        [Parameter(Mandatory = $false)][string]$ObjectType = 'deals',
        [Parameter(Mandatory = $false)][string]$AccessToken
    )

    $propertyDefinitions = Get-HubspotPropertyDefinitions -ObjectType $ObjectType -AccessToken $AccessToken
    $convertedRecord = [ordered]@{}
    $pipelineId = $null
    $dealStageId = $null

    if ($Record.PSObject.Properties.Name -contains 'id') {
        $convertedRecord.Id = [string]$Record.id
    }

    if ($Record.PSObject.Properties.Name -contains 'archived') {
        $convertedRecord.Archived = [bool]$Record.archived
    }

    foreach ($metaPropertyName in @('createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'propertiesWithHistory', 'associations')) {
        if ($Record.PSObject.Properties.Name -contains $metaPropertyName) {
            $convertedRecord[$metaPropertyName] = Convert-HubspotPropertyValue -Value $Record.$metaPropertyName
        }
    }

    if ($Record.PSObject.Properties.Name -contains 'properties' -and $Record.properties) {
        foreach ($property in $Record.properties.PSObject.Properties) {
            $definition = $null
            if ($propertyDefinitions.ContainsKey($property.Name)) {
                $definition = $propertyDefinitions[$property.Name]
            }

            $convertedValue = Convert-HubspotPropertyValue -Value $property.Value -Definition $definition
            $convertedRecord[$property.Name] = $convertedValue

            if ($property.Name -ieq 'pipeline') {
                $pipelineId = [string]$convertedValue
            }

            if ($property.Name -ieq 'dealstage') {
                $dealStageId = [string]$convertedValue
            }
        }
    }

    if (-not [string]::IsNullOrWhiteSpace($dealStageId)) {
        $dealStageDefinition = Get-HubspotDealStageDefinition -PipelineId $pipelineId -StageId $dealStageId -AccessToken $AccessToken
        if ($dealStageDefinition) {
            $convertedRecord.dealstageLabel = $dealStageDefinition.Label

            if ([string]::IsNullOrWhiteSpace($convertedRecord.pipelineLabel) -and -not [string]::IsNullOrWhiteSpace($dealStageDefinition.PipelineLabel)) {
                $convertedRecord.pipelineLabel = $dealStageDefinition.PipelineLabel
            }
        }
    }

    return [pscustomobject]$convertedRecord
}

<#
.SYNOPSIS
Stores the HubSpot bearer token securely in Windows Credential Manager.
 
.DESCRIPTION
Saves the token as a secure credential entry for local reuse by module commands.
You can pass a secure string or plain string token.
 
.EXAMPLE
$secureToken = Read-Host 'HubSpot Token' -AsSecureString
Set-HubspotAccessToken -SecureToken $secureToken
 
.EXAMPLE
Set-HubspotAccessToken -SecureToken (Read-Host 'HubSpot Token' -AsSecureString) -ServiceUserName 'Hubspot_Deals' -Target 'Hubspot_Deals_BearerToken'
 
.EXAMPLE
# Troubleshooting: Bei INVALID_AUTHENTICATION Token rotieren und neu speichern.
$secureToken = Read-Host 'Neuer HubSpot Token' -AsSecureString
Set-HubspotAccessToken -SecureToken $secureToken
#>

function Set-HubspotAccessToken {
    [CmdletBinding(DefaultParameterSetName = 'Secure')]
    param (
        [Parameter(Mandatory = $true, ParameterSetName = 'Secure')]
        [Security.SecureString]$SecureToken,

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

        [Parameter(Mandatory = $false)]
        [string]$ServiceUserName = $script:HubspotSettings.ServiceUserName,

        [Parameter(Mandatory = $false)]
        [string]$Target = $script:HubspotSettings.TokenTarget
    )

    $secureValue = $SecureToken
    if ($PSCmdlet.ParameterSetName -eq 'Plain') {
        $secureValue = ConvertTo-SecureString -String $Token -AsPlainText -Force
    }

    $credential = [PSCredential]::new($ServiceUserName, $secureValue)
    Set-Credential -Target $Target -Credential $credential -Type Generic -Persistence Enterprise -Description 'HubSpot Deals API token' > $null
}

function Get-HubspotBearerToken {
    param (
        [Parameter(Mandatory = $false)]
        [string]$AccessToken,

        [Parameter(Mandatory = $false)]
        [switch]$ForcePrompt
    )

    if (-not [string]::IsNullOrWhiteSpace($AccessToken)) {
        return $AccessToken
    }

    Initialize-AutomationEnvironment

    if ($script:env_runbook) {
        $automationCredentialCommand = Get-Command -Name Get-AutomationPSCredential -ErrorAction SilentlyContinue
        if (-not $automationCredentialCommand) {
            throw 'Get-AutomationPSCredential ist in dieser Runbook-Umgebung nicht verfuegbar.'
        }

        try {
            $credential = Get-AutomationPSCredential -Name $script:HubspotSettings.ServiceUserName -ErrorAction Stop
        }
        catch {
            throw "AutomationPSCredential mit dem Namen $($script:HubspotSettings.ServiceUserName) konnte nicht gelesen werden."
        }

        if (-not $credential) {
            throw "AutomationPSCredential mit dem Namen $($script:HubspotSettings.ServiceUserName) wurde nicht gefunden."
        }

        return Convert-SecureStringToPlainText -SecureString $credential.Password
    }

    if (-not $ForcePrompt.IsPresent) {
        $storedCredential = Get-StoredCredentialSafe -Target $script:HubspotSettings.TokenTarget
        if ($storedCredential) {
            return Convert-SecureStringToPlainText -SecureString $storedCredential.Password
        }
    }

    if ($script:env_runbook) {
        throw "Kein HubSpot Token gefunden. Hinterlegen Sie ihn mit Set-HubspotAccessToken oder uebergeben Sie -AccessToken direkt."
    }

    if ($ForcePrompt.IsPresent) {
        Write-Warning 'Der gespeicherte HubSpot Access-Token ist ungueltig oder abgelaufen. Bitte geben Sie den aktuellen Token erneut ein. Er wird danach gespeichert.'
    }
    else {
        Write-Host 'HubSpot Access-Token wird benoetigt' -ForegroundColor Yellow
    }

    $credential = Microsoft.PowerShell.Security\Get-Credential -UserName $script:HubspotSettings.ServiceUserName -Message 'Geben Sie den HubSpot Access-Token ein'
    if (-not $credential) {
        throw 'HubSpot Access-Token wurde nicht eingegeben.'
    }

    Set-HubspotAccessToken -SecureToken $credential.Password -ServiceUserName $script:HubspotSettings.ServiceUserName -Target $script:HubspotSettings.TokenTarget
    return Convert-SecureStringToPlainText -SecureString $credential.Password
}

<#
.SYNOPSIS
Executes a HubSpot Deals API request via the module REST wrapper.
 
.DESCRIPTION
Builds the deals endpoint path automatically and sends authenticated HTTP requests.
This command is useful for advanced/custom requests against deals resources.
 
.EXAMPLE
Invoke-HubspotRest
 
.EXAMPLE
Invoke-HubspotRest -Path '42824620728'
 
.EXAMPLE
Invoke-HubspotRest -Path 'search' -Method 'POST' -Body @{
    filterGroups = @(@{ filters = @(@{ propertyName = 'dealname'; operator = 'CONTAINS_TOKEN'; value = 'Test' }) })
    limit = 20
}
 
.EXAMPLE
Invoke-HubspotRest -Path '42824620728' -Method 'PATCH' -Body @{ properties = @{ dealname = 'Updated via Invoke-HubspotRest' } }
 
.EXAMPLE
# Troubleshooting: HubSpot-Fehlerdetails fuer Status/Scope-Diagnose sichtbar machen.
try {
    Invoke-HubspotRest -Path 'search' -Method 'POST' -Body @{ filterGroups = @(@{ filters = @(@{ propertyName = 'dealname'; operator = 'CONTAINS_TOKEN'; value = 'Test' }) }); limit = 1 } -ErrorAction Stop
}
catch {
    $PSItem | Format-List * -Force
}
#>

function Invoke-HubspotRest {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false)][string]$Path,
        [Parameter(Mandatory = $false)][string]$Method = 'GET',
        [Parameter(Mandatory = $false)][hashtable]$Query,
        [Parameter(Mandatory = $false)]$Body,
        [Parameter(Mandatory = $false)][string]$AccessToken,
        [Parameter(Mandatory = $false)][int]$RetryCount = $script:HubspotSettings.DefaultRetryCount,
        [Parameter(Mandatory = $false)][double]$ThrottleDelaySeconds = $script:HubspotSettings.DefaultThrottleDelaySeconds
    )

    $resourcePath = $script:HubspotSettings.DealsPath.Trim('/ ')
    if ($Path) {
        $resourcePath = "$resourcePath/$($Path.TrimStart('/'))"
    }

    return Invoke-HubspotRequest -ResourcePath $resourcePath -Method $Method -Query $Query -Body $Body -AccessToken $AccessToken -RetryCount $RetryCount -ThrottleDelaySeconds $ThrottleDelaySeconds
}

<#
.SYNOPSIS
Gets a single deal by ID.
 
.DESCRIPTION
Reads one deal from HubSpot. By default HubSpot standard attributes are returned.
Use -AllProperties to explicitly request all known deal properties.
 
.EXAMPLE
Get-HubspotDeal -Id '42824620728'
 
.EXAMPLE
Get-HubspotDeal -Id '42824620728' -AllProperties
 
.EXAMPLE
Get-HubspotDeal -Id '42824620728' -Properties @('dealname','pipeline','dealstage','hubspot_owner_id')
 
.EXAMPLE
Get-HubspotDeal -Id '42824620728' -PropertiesWithHistory @('dealstage') -Associations @('companies','contacts')
 
.EXAMPLE
Get-HubspotDeal -Id '42824620728' -Archived
 
.EXAMPLE
# Troubleshooting: Bei 404 den Datensatz explizit als archiviert abrufen.
$id = '42824620728'
try {
    Get-HubspotDeal -Id $id -ErrorAction Stop
}
catch {
    Get-HubspotDeal -Id $id -Archived
}
#>

function Get-HubspotDeal {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)][string]$Id,
        [Parameter(Mandatory = $false)][string[]]$Properties,
        [Parameter(Mandatory = $false)][switch]$AllProperties,
        [Parameter(Mandatory = $false)][string[]]$PropertiesWithHistory,
        [Parameter(Mandatory = $false)][string[]]$Associations,
        [Parameter(Mandatory = $false)][switch]$Archived,
        [Parameter(Mandatory = $false)][string]$AccessToken
    )

    $query = @{}
    if ($Properties) {
        $query.properties = $Properties
    }

    if ($AllProperties.IsPresent -and -not $Properties) {
        $propertyDefinitions = Get-HubspotPropertyDefinitions -ObjectType 'deals' -AccessToken $AccessToken
        if ($propertyDefinitions -and $propertyDefinitions.Keys.Count -gt 0) {
            $query.properties = @($propertyDefinitions.Keys | Sort-Object)
        }
    }

    if ($PropertiesWithHistory) {
        $query.propertiesWithHistory = $PropertiesWithHistory
    }

    if ($Associations) {
        $query.associations = $Associations
    }

    if ($Archived.IsPresent) {
        $query.archived = 'true'
    }

    return Convert-HubspotRecordToObject -Record (Invoke-HubspotRest -Path $Id -Query $query -AccessToken $AccessToken) -AccessToken $AccessToken
}

<#
.SYNOPSIS
Returns GUI-like detailed property rows for one deal.
 
.DESCRIPTION
Builds a property-focused output with labels, groups, types, raw values,
and display values (for example translated stage labels and owner names).
 
.EXAMPLE
Get-HubspotDealDetails -Id '42824620728'
 
.EXAMPLE
Get-HubspotDealDetails -Id '42824620728' -Properties @('dealname','pipeline','dealstage','hubspot_owner_id')
 
.EXAMPLE
Get-HubspotDealDetails -Id '42824620728' -Properties @('nda_safe') -IncludeEmpty
 
.EXAMPLE
Get-HubspotDealDetails -Id '42824620728' -Archived
 
.EXAMPLE
# Troubleshooting: Wenn DisplayValue nur ID bleibt, Scope-Hinweise pruefen.
$rows = Get-HubspotDealDetails -Id '42824620728' -Properties @('hubspot_owner_id')
$rows | Select-Object PropertyName, Value, DisplayValue
#>

function Get-HubspotDealDetails {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)][string]$Id,
        [Parameter(Mandatory = $false)][string[]]$Properties,
        [Parameter(Mandatory = $false)][switch]$IncludeEmpty,
        [Parameter(Mandatory = $false)][switch]$Archived,
        [Parameter(Mandatory = $false)][string]$AccessToken
    )

    $propertyDefinitions = Get-HubspotPropertyDefinitions -ObjectType 'deals' -AccessToken $AccessToken

    $propertyNames = @()
    $explicitPropertiesRequested = $false
    if ($Properties -and $Properties.Count -gt 0) {
        $explicitPropertiesRequested = $true
        $propertyNames = @($Properties | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
    }
    else {
        $propertyNames = @($propertyDefinitions.Keys | Sort-Object)
    }

    $query = @{}
    if ($propertyNames.Count -gt 0) {
        $query.properties = $propertyNames
    }

    if ($Archived.IsPresent) {
        $query.archived = 'true'
    }

    $record = $null
    try {
        $record = Invoke-HubspotRest -Path $Id -Query $query -AccessToken $AccessToken
    }
    catch {
        $statusCode = $null
        if ($PSItem.Exception.Response -and $PSItem.Exception.Response.StatusCode) {
            $statusCode = [int]$PSItem.Exception.Response.StatusCode
        }

        if (-not $Archived.IsPresent -and $statusCode -eq 404) {
            $query.archived = 'true'
            $record = Invoke-HubspotRest -Path $Id -Query $query -AccessToken $AccessToken
        }
        else {
            throw
        }
    }

    $converted = Convert-HubspotRecordToObject -Record $record -AccessToken $AccessToken

    $pipelineId = $null
    if ($record -and $record.properties -and ($record.properties.PSObject.Properties.Name -contains 'pipeline')) {
        $pipelineId = [string]$record.properties.pipeline
    }

    $details = @()
    foreach ($propertyName in $propertyNames) {
        $definition = $null
        if ($propertyDefinitions.ContainsKey($propertyName)) {
            $definition = $propertyDefinitions[$propertyName]
        }

        $value = $null
        if ($converted.PSObject.Properties.Name -contains $propertyName) {
            $value = $converted.$propertyName
        }

        if (-not $IncludeEmpty.IsPresent -and -not $explicitPropertiesRequested) {
            $isEmptyString = ($value -is [string] -and [string]::IsNullOrWhiteSpace($value))
            if ($null -eq $value -or $isEmptyString) {
                continue
            }
        }

        $displayValue = Resolve-HubspotPropertyDisplayValue -PropertyName $propertyName -Value $value -Definition $definition -PipelineId $pipelineId -AccessToken $AccessToken

        $details += [pscustomobject]@{
            Id = $Id
            PropertyName = $propertyName
            Label = if ($definition -and $definition.label) { [string]$definition.label } else { $propertyName }
            GroupName = if ($definition -and $definition.groupName) { [string]$definition.groupName } else { $null }
            Type = if ($definition -and $definition.type) { [string]$definition.type } else { $null }
            FieldType = if ($definition -and $definition.fieldType) { [string]$definition.fieldType } else { $null }
            Value = $value
            DisplayValue = $displayValue
        }
    }

    return $details
}

<#
.SYNOPSIS
Gets one page or all pages of deals.
 
.DESCRIPTION
Lists deals with paging support. Use -All to follow paging cursors.
Use -AllProperties to request all known deal properties.
 
.EXAMPLE
Get-HubspotDeals
 
.EXAMPLE
Get-HubspotDeals -Limit 50
 
.EXAMPLE
Get-HubspotDeals -All
 
.EXAMPLE
Get-HubspotDeals -AllProperties -Limit 100
 
.EXAMPLE
Get-HubspotDeals -Properties @('dealname','dealstage','pipeline','hubspot_owner_id') -All
 
.EXAMPLE
Get-HubspotDeals -Archived -All
 
.EXAMPLE
Get-HubspotDeals -After 'NTI4MjQ2MjA3Mjg='
 
.EXAMPLE
# Troubleshooting: Bei Rate-Limits kleinere Seiten laden und alle Seiten iterieren.
Get-HubspotDeals -Limit 20 -All
#>

function Get-HubspotDeals {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false)][int]$Limit = $script:HubspotSettings.DefaultPageSize,
        [Parameter(Mandatory = $false)][string]$After,
        [Parameter(Mandatory = $false)][string[]]$Properties,
        [Parameter(Mandatory = $false)][switch]$AllProperties,
        [Parameter(Mandatory = $false)][string[]]$PropertiesWithHistory,
        [Parameter(Mandatory = $false)][string[]]$Associations,
        [Parameter(Mandatory = $false)][switch]$Archived,
        [Parameter(Mandatory = $false)][switch]$All,
        [Parameter(Mandatory = $false)][string]$AccessToken
    )

    $safeLimit = [Math]::Max(1, [Math]::Min(200, $Limit))
    $allResults = @()
    $cursor = $After

    do {
        $query = @{
            limit = $safeLimit
        }

        if (-not [string]::IsNullOrWhiteSpace($cursor)) {
            $query.after = $cursor
        }

        if ($Properties) {
            $query.properties = $Properties
        }
        elseif ($AllProperties.IsPresent) {
            $propertyDefinitions = Get-HubspotPropertyDefinitions -ObjectType 'deals' -AccessToken $AccessToken
            if ($propertyDefinitions -and $propertyDefinitions.Keys.Count -gt 0) {
                $query.properties = @($propertyDefinitions.Keys | Sort-Object)
            }
        }

        if ($PropertiesWithHistory) {
            $query.propertiesWithHistory = $PropertiesWithHistory
        }

        if ($Associations) {
            $query.associations = $Associations
        }

        if ($Archived.IsPresent) {
            $query.archived = 'true'
        }

        $response = Invoke-HubspotRest -Query $query -AccessToken $AccessToken
        $convertedResults = @($response.results | ForEach-Object { Convert-HubspotRecordToObject -Record $_ -AccessToken $AccessToken })
        $allResults += $convertedResults

        if (-not $All.IsPresent) {
            return $convertedResults
        }

        $cursor = $null
        if ($response.paging -and $response.paging.next -and $response.paging.next.after) {
            $cursor = [string]$response.paging.next.after
        }
    }
    while (-not [string]::IsNullOrWhiteSpace($cursor))

    return $allResults
}

<#
.SYNOPSIS
Searches deals by property value.
 
.DESCRIPTION
Runs a deals search using CONTAINS_TOKEN against the selected property.
Supports paging via -All and optional selection of returned properties.
 
.EXAMPLE
Search-HubspotDeals -PropertyName 'dealname' -SearchTerm 'Holthausen'
 
.EXAMPLE
Search-HubspotDeals -PropertyName 'dealname' -SearchTerm 'Test' -Limit 20
 
.EXAMPLE
Search-HubspotDeals -PropertyName 'dealname' -SearchTerm 'Test' -Properties @('dealname','pipeline','dealstage')
 
.EXAMPLE
Search-HubspotDeals -PropertyName 'dealname' -SearchTerm 'Test' -All
 
.EXAMPLE
Search-HubspotDeals -PropertyName 'dealname' -SearchTerm 'Test' -Archived
 
.EXAMPLE
# Troubleshooting: Ungueltige Such-Properties schnell validieren.
try {
    Search-HubspotDeals -PropertyName 'Archived' -SearchTerm 'true' -ErrorAction Stop
}
catch {
    $PSItem.Exception.Message
}
#>

function Search-HubspotDeals {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)][string]$PropertyName,
        [Parameter(Mandatory = $true)][string]$SearchTerm,
        [Parameter(Mandatory = $false)][int]$Limit = $script:HubspotSettings.DefaultPageSize,
        [Parameter(Mandatory = $false)][string]$After,
        [Parameter(Mandatory = $false)][string[]]$Properties,
        [Parameter(Mandatory = $false)][switch]$Archived,
        [Parameter(Mandatory = $false)][switch]$All,
        [Parameter(Mandatory = $false)][string]$AccessToken
    )

    if ($PropertyName -ieq 'Archived') {
        throw 'Archived ist kein durchsuchbares Deal-Property. Verwenden Sie stattdessen -Archived mit einem echten Property-Namen oder Get-HubspotDeals -Archived.'
    }

    $safeLimit = [Math]::Max(1, [Math]::Min(200, $Limit))
    $allResults = @()
    $cursor = $After

    do {
        $body = @{
            filterGroups = @(
                @{
                    filters = @(
                        @{
                            propertyName = $PropertyName
                            operator = 'CONTAINS_TOKEN'
                            value = $SearchTerm
                        }
                    )
                }
            )
            limit = $safeLimit
        }

        if (-not [string]::IsNullOrWhiteSpace($cursor)) {
            $body.after = $cursor
        }

        if ($Properties) {
            $body.properties = $Properties
        }

        if ($Archived.IsPresent) {
            $body.archived = $true
        }

        $response = Invoke-HubspotRest -Path 'search' -Method 'POST' -Body $body -AccessToken $AccessToken
        $convertedResults = @($response.results | ForEach-Object { Convert-HubspotRecordToObject -Record $_ -AccessToken $AccessToken })
        $allResults += $convertedResults

        if (-not $All.IsPresent) {
            return $convertedResults
        }

        $cursor = $null
        if ($response.paging -and $response.paging.next -and $response.paging.next.after) {
            $cursor = [string]$response.paging.next.after
        }
    }
    while (-not [string]::IsNullOrWhiteSpace($cursor))

    return $allResults
}

<#
.SYNOPSIS
Creates a new deal.
 
.DESCRIPTION
Creates a deal from the provided property hashtable. Optional associations
can be provided when needed.
 
.EXAMPLE
New-HubspotDeal -Properties @{
    dealname = 'Neuer Deal'
    pipeline = 'default'
    dealstage = 'appointmentscheduled'
}
 
.EXAMPLE
New-HubspotDeal -Properties @{
    dealname = 'Neuer Deal mit Betrag'
    amount = '5000'
    closedate = '2026-12-31'
}
 
.EXAMPLE
New-HubspotDeal -Properties @{
    dealname = 'Deal mit Eigentuemer'
    hubspot_owner_id = '81455014'
}
 
.EXAMPLE
# Troubleshooting: Stage-Mapping pruefen und bei Fehlern mit interner Stage-ID anlegen.
New-HubspotDeal -Properties @{
    dealname = 'Fallback mit Stage-ID'
    pipeline = 'default'
    dealstage = 'appointmentscheduled'
}
#>

function New-HubspotDeal {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)][hashtable]$Properties,
        [Parameter(Mandatory = $false)]$Associations,
        [Parameter(Mandatory = $false)][string]$AccessToken
    )

    $body = @{
        properties = Convert-HubspotDealPropertiesForWrite -Properties $Properties -AccessToken $AccessToken
    }

    if ($PSBoundParameters.ContainsKey('Associations') -and $null -ne $Associations) {
        $body.associations = $Associations
    }

    return Convert-HubspotRecordToObject -Record (Invoke-HubspotRest -Method 'POST' -Body $body -AccessToken $AccessToken) -AccessToken $AccessToken
}

<#
.SYNOPSIS
Updates an existing deal.
 
.DESCRIPTION
Applies partial updates to one deal by ID using a property hashtable.
 
.EXAMPLE
Set-HubspotDeal -Id '42824620728' -Properties @{ dealname = 'Neuer Dealname' }
 
.EXAMPLE
Set-HubspotDeal -Id '42824620728' -Properties @{ dealstage = 'closedlost' }
 
.EXAMPLE
Set-HubspotDeal -Id '42824620728' -Properties @{ amount = '3000'; closedate = '2026-10-01' }
 
.EXAMPLE
Set-HubspotDeal -Id '42824620728' -Properties @{ hubspot_owner_id = '81455014' }
 
.EXAMPLE
# Troubleshooting: Stage stabil ueber interne ID statt Label setzen.
Set-HubspotDeal -Id '42824620728' -Properties @{ pipeline = 'default'; dealstage = 'closedlost' }
#>

function Set-HubspotDeal {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)][string]$Id,
        [Parameter(Mandatory = $true)][hashtable]$Properties,
        [Parameter(Mandatory = $false)][string]$AccessToken
    )

    $body = @{
        properties = Convert-HubspotDealPropertiesForWrite -Properties $Properties -AccessToken $AccessToken
    }

    return Convert-HubspotRecordToObject -Record (Invoke-HubspotRest -Path $Id -Method 'PATCH' -Body $body -AccessToken $AccessToken) -AccessToken $AccessToken
}

<#
.SYNOPSIS
Deletes a deal.
 
.DESCRIPTION
Deletes a deal by ID. Supports ShouldProcess and confirmation prompts.
 
.EXAMPLE
Remove-HubspotDeal -Id '42824620728'
 
.EXAMPLE
Remove-HubspotDeal -Id '42824620728' -Confirm:$false
 
.EXAMPLE
Remove-HubspotDeal -Id '42824620728' -WhatIf
 
.EXAMPLE
# Troubleshooting: Vor endgueltigem Loeschen zuerst Simulationslauf ausfuehren.
Remove-HubspotDeal -Id '42824620728' -WhatIf
Remove-HubspotDeal -Id '42824620728' -Confirm:$false
#>

function Remove-HubspotDeal {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param (
        [Parameter(Mandatory = $true)][string]$Id,
        [Parameter(Mandatory = $false)][string]$AccessToken
    )

    if ($PSCmdlet.ShouldProcess("Deal $Id", 'Delete')) {
        Invoke-HubspotRest -Path $Id -Method 'DELETE' -AccessToken $AccessToken > $null
    }
}