WrikeExplorer.psm1


# GENERAL
enum WrikeStatus {
    Scheduled
    InProgress
    Completed
    Cancelled
    Failed
}

# BI EXPORT
class WrikeDataExportResource {
    [string]$Name
    [uri]$Url
}

class WrikeDataExport {
    [string]$Id
    [datetime]$CompletedDate
    [WrikeStatus]$Status
    [WrikeDataExportResource[]]$Resources
}

enum WrikeColumnDataType {
    Number
    String
    Date
    Boolean
}

class WrikeDataExportColumn {
    [string] $Id
    [string] $Alias
    [WrikeColumnDataType] $DataType
    [object] $ForeignKey
}

class WrikeDataExportSchema {
    [string] $Id
    [string] $Alias
    [WrikeDataExportColumn[]] $Columns
}

# CONTACTS
enum WrikeUserType {
    Person
    Group
}

class WrikeUserProfile {
    [string] $AccountId
    [string] $Email
    [string] $Role
    [bool] $External
    [bool] $Admin
    [bool] $Owner
}

class WrikeContact {
    [string] $Id
    [string] $FirstName
    [string] $LastName
    [WrikeUserType] $Type
    [WrikeUserProfile[]] $Profiles
    [uri] $AvatarUrl
    [string] $TimeZone
    [string] $Locale
    [bool] $Deleted
    [bool] $Me
    [string[]] $MemberIds
    [object] $Metadata
    [bool] $MyTeam
    [string] $Title
    [string] $CompanyName
    [string] $Phone
    [string] $Location
    [string] $WorkScheduleId
    [object] $CurrentBillRate
    [object] $CurrentCostRate
}

enum WrikeRateSource {
    User
    JobRole
}

class WrikeBudgetRateChangeHistory {
    [WrikeRateSource] $RateSource
    [double] $RateValue
    [datetime] $StartDate
    [datetime] $EndDate
}

class WrikeContactHistory {
    [string] $Id
    [WrikeBudgetRateChangeHistory[]] $BillRateHistory
    [WrikeBudgetRateChangeHistory[]] $CostRateHistory
}

class WrikeErrorDescription {
    [string] $Error
    [string] $ErrorDescription
}
function ConvertFrom-WrikeErrorDescription {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [WrikeErrorDescription]
        $WrikeErrorDescription
    )

    process {
        $categoryMap = @{
            invalid_request = 'ProtocolError'
            invalid_parameter = 'InvalidArgument'
            parameter_required = 'InvalidData'
            not_authorized = 'SecurityError'
            access_forbidden = 'PermissionDenied'
            not_allowed = 'PermissionDenied'
            resource_not_found = 'ObjectNotFound'
            method_not_found = 'InvalidOperation'
            too_many_requests = 'LimitsExceeded'
            rate_limit_exceeded = 'LimitsExceeded'
            server_error = 'NotSpecified'
        }
        $e = [exception]::new($WrikeErrorDescription.ErrorDescription)
        $category = [System.Management.Automation.ErrorCategory]::($categoryMap.($WrikeErrorDescription.Error))
        $record = [System.Management.Automation.ErrorRecord]::new($e, $WrikeErrorDescription.Error, $category, $null)
        Write-Output $record
    }
}
function ConvertTo-PlainText {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [securestring]
        $Secret
    )

    process {
        Write-Output ([pscredential]::new('a', $Secret).GetNetworkCredential().Password)
    }
}
function Get-StandardHeaders {
    [CmdletBinding()]
    param ()

    process {
        $headers = @{
            Authorization = "Bearer $($script:Config.Wrike.AccessToken | ConvertTo-PlainText)"
        }
        Write-Output $headers
    }
}
function Import-AccessToken {
    [CmdletBinding()]
    param ()

    process {
        $path = Join-Path $script:Config.AppDataPath 'accesstoken.txt'
        if (-not (Test-Path -Path $path)) {
            return
        }
        try {
            $token = Get-Content -Path $path | ConvertTo-SecureString
            $script:Config.Wrike.AccessToken = $token
        }
        catch {
            Write-Error "Failed to import Wrike API App access token from '$path'. Consider deleting the file and using Set-WrikeAccessToken again."
        }
    }
}
function Remove-AccessToken {
    [CmdletBinding()]
    param (
    )

    process {
        $script:Config.Wrike.AccessToken = $null
        $path = Join-Path $script:Config.AppDataPath 'accesstoken.txt'
        Remove-Item -Path $path -Force
    }
}
function Save-AccessToken {
    [CmdletBinding()]
    param (
        # Specifies the API App access token to use for authentication
        [Parameter(Mandatory)]
        [securestring]
        $AccessToken,

        # Specifies that the token should not be persisted to disk
        [Parameter()]
        [switch]
        $Ephemeral
    )

    process {
        $script:Config.Wrike.AccessToken = $AccessToken
        if (-not $Ephemeral) {
            $path = Join-Path $script:Config.AppDataPath 'accesstoken.txt'
            $AccessToken | ConvertFrom-SecureString | Set-Content -Path $path
        }
    }
}
function ValidateAccessToken {
    [CmdletBinding()]
    param ()

    process {
        if ([string]::IsNullOrWhiteSpace($script:Config.Wrike.AccessToken)) {
            throw "Wrike API App access token has not been set. Please use Set-WrikeAccessToken."
        }
    }
}
function Clear-WrikeAccessToken {
    [CmdletBinding(SupportsShouldProcess)]
    param (
    )

    process {
        if ($PSCmdlet.ShouldProcess("Wrike API Access Token", "Clear")) {
            Remove-AccessToken
        }
    }
}
function Get-WrikeContact {
    [CmdletBinding()]
    [OutputType([WrikeContact])]
    param (
        # Specifies one or more optional ContactId values for known contacts
        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [Alias('Id')]
        [string[]]
        $ContactId,

        # Specifies that the Wrike Contact record for the current user should be returned
        [Parameter()]
        [Alias('CurrentUser')]
        [switch]
        $Me,

        # Specifies that only deleted Wrike Contact records should be returned
        [Parameter()]
        [switch]
        $Deleted,

        # Specifies a set of optional fields to be included in the Wrike Contact response model. Normally these values are empty.
        [Parameter()]
        [ValidateSet('metadata', 'workScheduleId', 'currentBillRate', 'currentCostRate', IgnoreCase = $false)]
        [string[]]
        $Include
    )

    process {
        if ($ContactId.Count -gt 100) {
            Write-Error 'The Wrike API imposes a limit of 100 contact IDs in a contact history query.'
            return
        }
        $query = @{}
        if ($MyInvocation.BoundParameters.ContainsKey('Me')) {
            $query.me = $Me.ToString().ToLower()
        }
        if ($MyInvocation.BoundParameters.ContainsKey('Deleted')) {
            $query.deleted = $Deleted.ToString().ToLower()
        }
        if ($MyInvocation.BoundParameters.ContainsKey('Include')) {
            # De-duplicate the list of optional fields
            $Include = $Include | Group-Object | Select-Object -ExpandProperty Name
            $json = $Include | ConvertTo-Json -Compress
            if ($Include.Count -lt 2) {
                $json = "[$json]"
            }
            $query.fields = $json
        }
        $path = "contacts"
        if ($null -ne $ContactId -and $ContactId.Count -gt 0) {
            $path += '/' + [string]::Join(',', $ContactId)
        }
        Invoke-WrikeApi -Path $path -ResponseType contacts -Query $query
    }
}
function Get-WrikeContactHistory {
    [CmdletBinding()]
    [OutputType([WrikeContactHistory])]
    param (
        # Specifies one or more optional ContactId values for known contacts
        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [Alias('Id')]
        [string[]]
        $ContactId,

        # Specifies the inclusive start of the time range for the contact history request
        [Parameter()]
        [datetime]
        $UpdatedAfter,

        # Specifies the inclusive end of the time range for the contact history request
        [Parameter()]
        [datetime]
        $UpdatedBefore,

        # Specifies a set of optional fields to be included in the Wrike Contact response model. Normally these values are empty.
        [Parameter()]
        [ValidateSet('billRate', 'costRate', IgnoreCase = $false)]
        [string[]]
        $Include
    )

    process {
        if ($null -ne $UpdatedAfter -and $null -ne $UpdatedBefore -and $UpdatedBefore -le $UpdatedAfter) {
            Write-Error 'UpdatedBefore cannot be less than or equal to UpdatedAfter.'
            return
        }
        if ($ContactId.Count -gt 100) {
            Write-Error 'The Wrike API imposes a limit of 100 contact IDs in a contact history query.'
            return
        }

        $query = @{}
        if ($MyInvocation.BoundParameters.ContainsKey('Include')) {
            # De-duplicate the list of optional fields
            $Include = $Include | Group-Object | Select-Object -ExpandProperty Name
            $json = $Include | ConvertTo-Json -Compress
            if ($Include.Count -lt 2) {
                $json = "[$json]"
            }
            $query.fields = $json
        }
        $updatedDate = @{}
        if ($MyInvocation.BoundParameters.ContainsKey('UpdatedAfter')) {
            $updatedDate.start = $UpdatedAfter.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
        }
        if ($MyInvocation.BoundParameters.ContainsKey('UpdatedBefore')) {
            $updatedDate.end = $UpdatedBefore.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
        }
        if ($updatedDate.Keys.Count -gt 0) {
            $json = $updatedDate | ConvertTo-Json -Compress
            $query.updatedDate = $json
        }

        $path = 'contacts'
        if ($null -ne $ContactId -and $ContactId.Count -gt 0) {
            $path += '/' + [string]::Join(',', $ContactId)
        }
        $path += '/contacts_history'
        Invoke-WrikeApi -Path $path -ResponseType contactsHistory -Query $query
    }
}
function Get-WrikeDataExport {
    [CmdletBinding()]
    [OutputType([WrikeDataExport])]
    param()

    process {
        Invoke-WrikeApi -Path 'data_export' -ResponseType dataExport
    }
}
function Get-WrikeDataExportSchema {
    [CmdletBinding()]
    param ()

    process {
        Invoke-WrikeApi -Path 'data_export_schema' -ResponseType dataExportSchema
    }
}
function Get-WrikeExplorerConfig {
    [CmdletBinding()]
    param (

    )

    process {
        Write-Output $script:Config
    }
}
function Invoke-WrikeApi {
    [CmdletBinding()]
    param (
        # Specifies the HTTP method to use for the API call
        [Parameter()]
        [ValidateSet('Get', 'Post', 'Delete', 'Put', 'Patch', 'Head', 'Options', 'Merge', 'Trace', 'Default')]
        [string]
        $Method = 'Get',

        # Specifies the full, or base uri to use for the Wrike API call. By default this will be the BaseUri from the module's configuration file.
        [Parameter()]
        [Uri]
        $Uri = [uri]$script:Config.Wrike.BaseUri,

        # Specifies the relative path of the Wrike API to call
        [Parameter()]
        [string]
        $Path = [string]::Empty,

        # Specifies the query string keys and values if applicable
        [Parameter()]
        [hashtable]
        $Query = @{},

        # Specifies any additional headers needed. The Authorization header will be added for you, and if you supply it manually it will be overwritten.
        [Parameter()]
        [hashtable]
        $Headers = @{},

        # Specifies the body for the API call if applicable
        [Parameter()]
        [object]
        $Body,

        # Specifies the path to save files for Wrike API calls that are intended to download data
        [Parameter()]
        [string]
        $OutFile,

        # Specifies the expected response type. This determines the class used to parse the results into a strong type.
        [Parameter()]
        [string]
        $ResponseType
    )

    begin {
        ValidateAccessToken
    }

    process {
        if ($MyInvocation.BoundParameters.ContainsKey('ResponseType') -and -not $script:ResponseType.ContainsKey($ResponseType)) {
            throw "Unexpected ResponseType passed to Invoke-WrikeApi: $ResponseType"
        }

        $Path = $Path.TrimStart('/')
        $uriBuilder = [uribuilder]([uri]::new($Uri, $Path))
        $queryStringCollection = [System.Web.HttpUtility]::ParseQueryString($uriBuilder.Query)
        $Query.Keys | ForEach-Object {
            $queryStringCollection.Add($_, $Query.$_)
        }
        $uriBuilder.Query = $queryStringCollection.ToString()
        $standardHeaders = Get-StandardHeaders
        $standardHeaders.Keys | ForEach-Object { $Headers.$_ = $standardHeaders.$_ }

        $requestParams = @{
            Method = $Method
            Uri = $uriBuilder.Uri
            Headers = $Headers
        }
        foreach ($p in 'Body', 'OutFile') {
            if ($MyInvocation.BoundParameters.ContainsKey($p)) {
                Write-Verbose "Adding parameter '$p' with value '$($MyInvocation.BoundParameters[$p])' to Invoke-RestMethod invocation "
                $requestParams.$p = $MyInvocation.BoundParameters[$p]
            }
        }
        try {
            $response = Invoke-RestMethod @requestParams
            if ($MyInvocation.BoundParameters.ContainsKey('ResponseType')) {
                if ($response.kind -eq $ResponseType) {
                    $dataType = $script:ResponseType.$ResponseType
                    foreach ($data in $response.data) {
                        $obj = $dataType::new()
                        foreach ($property in $data | Get-Member -MemberType NoteProperty | Select-Object -ExpandProperty Name) {
                            $obj.$property = $data.$property
                        }
                        Write-Output $obj
                    }
                }
                else {
                    Write-Error "Unexpected response from Wrike api: $($response | ConvertTo-Json)"
                }
            }
            elseif ($response) {
                Write-Output $response
            }
        }
        catch [System.Net.Http.HttpRequestException], [System.InvalidOperationException] {
            $errorRecord = $_
            Write-Verbose "Handling $($errorRecord.Exception.GetType()) exception"
            if ($errorDescription = ($errorRecord.ErrorDetails.Message | ConvertFrom-Json) -as [WrikeErrorDescription]) {
                Write-Verbose "Successfully parsed Wrike API error response"
                $errorRecord = $errorDescription | ConvertFrom-WrikeErrorDescription
            }
            Write-Error -ErrorRecord $errorRecord
        }
    }
}
function Request-WrikeDataExport {
    [CmdletBinding()]
    [OutputType([WrikeDataExport])]
    param(
        # Specifies the maximum age of the BI Export data before a refresh request is issued.
        [Parameter()]
        [TimeSpan]
        $MaxAge = ( New-TimeSpan -Hours 12 ),

        # Specifies that if a new BI Export request is issued, the command should block until the request is completed and the results are returned
        [Parameter()]
        [switch]
        $Wait,

        # Specifies the maximum time to wait for a result.
        [Parameter()]
        [TimeSpan]
        $Timeout = [TimeSpan]::MaxValue
    )

    process {
        $minAge = New-TimeSpan -Hours 1
        if ($MaxAge -lt $minAge) {
            Write-Verbose "MaxAge is less than 1 hour. MaxAge will be set to 1 hour."
            $MaxAge = $minAge
        }

        $data = Get-WrikeDataExport
        $now = if ($data.CompletedDate.Kind -eq [System.DateTimeKind]::Utc) { (Get-Date).ToUniversalTime() } else { Get-Date }
        if ($null -ne $data -and $now - $data.CompletedDate -le $MaxAge) {
            # The last report is not older than MaxAge so we just return it
            Write-Output $data
            return
        }

        # Either there is no BI Export data available or it's older than MaxAge
        Write-Verbose "Requesting updated BI Export data"
        $now = Get-Date
        $expireTime = if ($Timeout -eq [TimeSpan]::MaxValue) { [datetime]::MaxValue } else { $now.Add($Timeout) }
        $data = Invoke-WrikeApi -Method Post -Path 'data_export' -ResponseType dataExport

        if (-not $Wait) {
            # Return early with a Status of Scheduled or InProgress probably
            Write-Output $data
            return
        }
        Write-Verbose "Waiting until $expireTime"
        $completedStates = @([WrikeStatus]::Completed, [WrikeStatus]::Cancelled, [WrikeStatus]::Failed)
        $requestInterval = New-TimeSpan -Seconds $script:Config.Wrike.RequestIntervalSeconds
        while ($data.Status -notin $completedStates -and (Get-Date) -lt $expireTime) {
            Write-Verbose "BI Export status is $($data.Status). Checking again in $requestInterval. . ."
            Start-Sleep -Seconds $requestInterval.TotalSeconds
            $data = Request-WrikeDataExport -MaxAge $MaxAge
        }
        Write-Output $data
    }
}
function Save-WrikeDataExport {
    [CmdletBinding()]
    param(
        # Specifies the WrikeDataExport data returned from Get-WrikeDataExport or Request-WrikeDataExport. If ommitted, a call will be made to Get-WrikeDataExport automatically.
        [Parameter(ValueFromPipeline)]
        [WrikeDataExport]
        $WrikeDataExport,

        # Specifies the directory where all Wrike BI Export resources will be written as CSV files. Path will be created if it doesn't exist, and existing files will be overwritten.
        [Parameter(Mandatory)]
        [string]
        $Destination
    )

    process {
        $null = New-Item -Path $Destination -ItemType Directory -Force -ErrorAction Stop
        if ($null -eq $WrikeDataExport) {
            $WrikeDataExport = Get-WrikeDataExport
            if ($null -eq $WrikeDataExport) {
                Write-Error "There is no Wrike BI Export data available. Please use Request-WrikeDataExport to refresh the BI Export data."
                return
            }
        }
        foreach ($resource in $WrikeDataExport.Resources) {
            $requestParams = @{
                Uri = $resource.Url
                OutFile = Join-Path $Destination "$($resource.Name).csv"
            }
            $ProgressPreference = 'SilentlyContinue'
            Invoke-WrikeApi @requestParams
        }
    }
}
function Set-WrikeAccessToken {
    [CmdletBinding(SupportsShouldProcess)]
    param (
        # Specifies the API App access token to use for authentication. It is recommended to provide a securestring, but a string can also be provided.
        [Parameter(Mandatory)]
        [object]
        $AccessToken,

        # Specifies that the token should not be persisted to disk
        [Parameter()]
        [switch]
        $Ephemeral
    )

    process {
        $valueType = $AccessToken.GetType().Name
        if ($valueType -eq 'String') {
            Write-Warning "The AccessToken value was received as a string. For better security it is recommended to use a securestring instead. Otherwise it's possible your PowerShell command history could be read, and secrets like these could be stolen. Try using 'Read-Host -AsSecureString' to collect tokens, passwords, and secrets in the future."
            Write-Verbose 'Converting the AccessToken string to a securestring'
            $AccessToken = $AccessToken | ConvertTo-SecureString -AsPlainText -Force
        }
        elseif ($valueType -ne 'SecureString') {
            Write-Error "AccessToken is expected to be of type SecureString or String. Received a $valueType instead."
        }

        if ($PSCmdlet.ShouldProcess("Wrike API Access Token", "Set")) {
            Write-Verbose "Setting the Wrike API access token. Ephemeral = $Ephemeral"
            Save-AccessToken -AccessToken $AccessToken -Ephemeral:$Ephemeral
        }
    }
}
$script:Config = Import-PowerShellDataFile $PSScriptRoot\config.psd1
if ($script:Config.AppDataPath -eq 'Default') {
    $script:Config.AppDataPath = Join-Path -Path ([System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::ApplicationData)) -ChildPath 'WrikeExplorer'
}
if (-not (Test-Path $script:Config.AppDataPath)) {
    New-Item -Path $script:Config.AppDataPath -ItemType Directory
}

$script:ResponseType = @{
    contacts = [WrikeContact]
    dataExport = [WrikeDataExport]
    dataExportSchema = [WrikeDataExportSchema]
    contactsHistory = [WrikeContactHistory]
}

Import-AccessToken