MicrosoftMvp.psm1

#Requires -Module PSAuthClient, ThreadJob
using namespace Microsoft.PowerShell.Commands
using namespace System.Management.Automation
$ErrorActionPreference = 'Stop'

#region Model
class MvpActivity {
    [int]$id
    [int]$userProfileId = (Get-MvpContext).MvpProfile.id
    [string]$tenant = (Get-MvpContext).Tenant
    [string]$title
    [string]$description
    [string]$privateDescription
    [Nullable[datetime]]$date
    [Nullable[datetime]]$dateEnd
    [string]$url
    [string]$imageUrl
    [string]$activityTypeName
    [string]$activityTypeLocKey
    [string]$technologyFocusArea
    [string[]]$targetAudience
    [string[]]$additionalTechnologyAreas
    [int]$quantity
    [int]$reach
    [string]$role
    [string]$contributionRoleLocKey
    [string]$companySize
    [string]$companyName
    [string]$microsoftEvent
    [string]$microsoftEventOther
}
Update-TypeData -TypeName 'MvpActivity' -DefaultDisplayPropertySet 'id', 'title', 'activityTypeName', 'date' -Force

class MvpSearch {
    [int]$pageIndex = 1
    [int]$pageSize = 1000
    [string]$searchKey
    [string]$tenant = (Get-MvpContext).Tenant
    [string]$userProfileIdentifier = (Get-MvpContext).MvpProfile.userProfileIdentifier
    [string[]]$contributionTargetAudience = @()
    [string[]]$technologyFocusArea = @()
    [string[]]$type = @()
}

class MvpSearchResult {
    [int]$searchScore
    [int]$id
    [string]$title
    [string]$description
    [Nullable[datetime]]$date
    [string]$imageUrl
    [bool]$isFirstParty
    [bool]$isHighImpact
    [string]$type
    [string]$typeLocKey
    [string]$tenantName
    [bool]$deletable
    [bool]$editable
    [bool]$highImpactToggleable
}
Update-TypeData -TypeName 'MvpSearchResult' -DefaultDisplayPropertySet 'id', 'date', 'title', 'type' -Force

class ActivityTypes: IValidateSetValuesGenerator {
    [string[]] GetValidValues() {
        return $((Get-MvpActivityData).activityTypes.name)
    }
}
class TechnologyArea: IValidateSetValuesGenerator {
    [string[]] GetValidValues() {
        return (Get-MvpActivityData).technologyArea.technologyName
    }
}
class TargetAudience: IValidateSetValuesGenerator {
    [string[]] GetValidValues() {
        return (Get-MvpActivityData).targetAudience.name
    }
}


#endregion Model


#region Engine

#Defaults
$SCRIPT:Tenant = 'MVP'
[string]$SCRIPT:BaseUri = 'https://mavenapi-prod.azurewebsites.net/api/'

#This is used to track the context of the logged in user across runspaces to enable easy parallelization

if (-not [type]::GetType('MicrosoftMvp.UserProfile')) {
    Add-Type -TypeDefinition '
        namespace MicrosoftMvp;
        public static class UserProfile {
            public static object Context { get; set; }
        }
    '

}

function Get-MvpContext {
    [MicrosoftMvp.UserProfile]::Context
}

function Connect-Mvp {
    [CmdletBinding(DefaultParameterSetName = 'Interactive')]
    param(
        [ValidateNotNullOrEmpty()]
        [string]$BaseUri = $SCRIPT:BaseUri,
        [ValidateNotNullOrEmpty()]
        [string]$Tenant = $SCRIPT:Tenant,
        [switch]$Force
    )

    if ($Force) {
        Disconnect-Mvp
    }

    if ((Get-MvpContext).MvpProfile.userProfileIdentifier) {
        Write-Warning "You are already connected as $((Get-MvpContext).GraphUser.UserPrincipalName). Use -Force or Disconnect-Mvp first to reconnect."
        return
    }

    $code = Invoke-OAuth2AuthorizationEndpoint -uri 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize' -client_id 'e83f495c-dfa2-48e2-b1d9-3680b16e74e4' -scope 'openid profile User.Read offline_access' -redirect_uri 'https://mvp.microsoft.com' -response_type 'code' -response_mode 'fragment'

    #Use the authorization code to fetch a graph token. Origin is important here since we are impersonating an SPA, so we cannot use the typical method to get this token.
    $graphContext = Invoke-RestMethod -Uri 'https://login.microsoftonline.com/common/oauth2/v2.0/token' -Method Post -Body @{
        client_id     = $code.client_id
        redirect_uri  = $code.redirect_uri
        scope         = 'openid profile User.Read offline_access'
        code          = $code.code
        code_verifier = $code.code_verifier
        grant_type    = 'authorization_code'
        client_info   = 1
    } -Headers @{
        Origin  = 'https://mvp.microsoft.com'
        Referer = 'https://mvp.microsoft.com'
    }


    $me = Invoke-RestMethod -Uri 'https://graph.microsoft.com/v1.0/me' -Authentication Bearer -Token ($graphContext.access_token | ConvertTo-SecureString -AsPlainText)

    $mvpToken = Invoke-RestMethod -Uri 'https://login.microsoftonline.com/common/oauth2/v2.0/token' -Method Post -Body @{
        client_id     = $code.client_id
        refresh_token = $graphContext.refresh_token
        scope         = 'api://6dabb447-da84-4b4c-b68f-99f5215b2ca7/User.All openid profile offline_access'
        grant_type    = 'refresh_token'
        client_info   = 1
    } -Headers @{
        Origin  = 'https://mvp.microsoft.com'
        Referer = 'https://mvp.microsoft.com'
    }

    [MicrosoftMvp.UserProfile]::Context = @{
        Graph       = $graphContext
        GraphUser   = $me
        GraphExpire = (Get-Date).AddSeconds($graphContext.expires_in - 60)
        Mvp         = $mvpToken
        Tenant      = $Tenant
        Data        = @{}
    }
    (Get-MvpContext).MvpProfile = (Invoke-MvpRestMethod ('UserStatus/' + $me.userPrincipalName)).userStatusModel
    #Pre-Populate the data, have seen some race conditions if this is lazily evaluated
    [void](Get-MvpActivityData)
    Write-Verbose "Connected as $($me.UserName) to $BaseUri"
}

function Assert-MvpConnection {
    if (-not (Get-MvpContext)) {
        throw 'You must connect to the MVP API first using Connect-Mvp'
    }
}

function Disconnect-Mvp {
    [MicrosoftMvp.UserProfile]::Context = $null
}

function Invoke-MvpRestMethod {
    [CmdletBinding()]
    param(
        [Parameter(Position = 0, Mandatory, ParameterSetName = 'Endpoint')]
        [string]$Endpoint,

        $Body,

        [WebRequestMethod]$Method = 'GET',

        [ValidateNotNullOrEmpty()]
        [Parameter(ParameterSetName = 'Uri')]
        [string]$Uri = $BaseUri
    )
    Assert-MvpConnection
    if ($Endpoint) {
        $Uri = $BaseUri + $Endpoint
    }

    $irmParams = @{
        Uri            = $Uri
        Method         = $Method
        Body           = $Body
        Authentication = 'Bearer'
        Token          = (Get-MvpContext).Mvp.access_token | ConvertTo-SecureString -AsPlainText
        Debug          = $false
        Verbose        = $false
    }
    #Use the JSON content type for all methods except GET
    if ($Method -ne 'GET') {
        $irmParams['ContentType'] = 'application/json'
        $irmParams['Body'] = if ($irmParams['Body']) { $irmParams['Body'] | ConvertTo-Json -Depth 5 }
    }

    Write-Debug "MVP: $("$Method".ToUpper()) $Uri$(if ($irmParams['Body']) {' with body ' + $irmParams['Body']})"
    try {
        $response = Invoke-RestMethod @irmParams
    } catch {
        $jsonError = $($PSItem.ErrorDetails.Message | ConvertFrom-Json -ErrorAction SilentlyContinue)
        if ($jsonError.error) {
            $PSItem | ThrowCmdletError "$($jsonError.error.code): $($jsonError.error.message) $($jsonError.error.Details)"
            return
        }
        if ($jsonError.errors) {
            $PSItem | ThrowCmdletError "$($jsonError.title)`n$($jsonError.errors | Format-List | Out-String)"
        }
        throw $PSItem
    }
    return $response
}

function Get-MvpActivityData {
    <#
    .SYNOPSIS
    Fetches the activity data for the current tenant. Used for dynamic intellisense
    #>

    if (-not (Get-MvpContext).Data.Activity) {
        Write-Verbose 'MVP: Fetching Activity Data'
        $response = Invoke-MvpRestMethod 'SiteContent/Activity/Common/Data' -Body @{tenant = $SCRIPT:Tenant }
        (Get-MvpContext).Data.Activity = $response.data
    }
    return (Get-MvpContext).Data.Activity
}

#endregion Engine

function Search-MvpActivitySummary {
    [OutputType('MvpSearchResult')]
    param(
        [string]$Filter,
        [int]$First = 100,
        [int]$Skip = 0
    )

    $Search = [MvpSearch]@{
        pageIndex = $Skip + 1
        pageSize  = $First
    }
    if ($Filter) {
        $Search.SearchKey = $Filter
    }

    $response = (Invoke-MvpRestMethod -Endpoint 'Contributions/CommunityLeaderActivities/search' -Body $Search -Method 'POST')

    return [MvpSearchResult[]]$response.CommunityLeaderActivities
}

filter Get-MvpActivity {
    [OutputType('MvpActivity')]
    [CmdletBinding(DefaultParameterSetName = 'Id')]
    param(
        [Parameter(Position = 0, ParameterSetName = 'Filter')][string]$Filter,
        [int]$First = 100,
        [int]$Skip = 0,
        [Parameter(ValueFromPipelineByPropertyName, ParameterSetName = 'Id')][int]$Id
    )
    if (-not $Id) {
        Search-MvpActivitySummary -Filter $Filter -First $First -Skip $Skip | Get-MvpActivity
        return
    }
    try {
        [MvpActivity](Invoke-MvpRestMethod "Activities/$Id")
    } catch {
        $PSItem | ThrowCmdletError
    }
}

filter Set-MvpActivity {
    [OutputType('MvpActivity')]
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, ValueFromPipeline)][MvpActivity]$Activity
    )

    if (-not $PSCmdlet.ShouldProcess("$($Activity.title): $($Activity | ConvertTo-Json -Depth 2)", 'Update Activity')) {
        return
    }
    try {
        $response = Invoke-MvpRestMethod -Endpoint 'Activities' -Body @{activity = $Activity } -Method 'PUT'
        #HACK: Workaround for the fact the returned info is not the same type. Slight performance impact
        Get-MvpActivity -Id $response.id
    } catch {
        $PSItem | ThrowCmdletError
    }
}

filter Add-MvpActivity {
    [OutputType('MvpActivity')]
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, ValueFromPipeline)][MvpActivity]$Activity
    )

    if (-not $PSCmdlet.ShouldProcess("$($Activity.title): $($Activity | ConvertTo-Json -Depth 2)", 'Create Activity')) {
        return
    }
    try {
        $response = Invoke-MvpRestMethod -Endpoint 'Activities' -Body @{activity = $Activity } -Method 'POST'
        #HACK: Workaround for the fact the returned info is not the same type. Slight performance impact
        Get-MvpActivity -Id $response.contributionId
    } catch {
        $PSItem | ThrowCmdletError
    }
}

filter New-MvpActivity {
    param(
        [Parameter(Mandatory)][string]$Title,
        [Parameter(Mandatory)][string]$Description,
        [ValidateSet([ActivityTypes])]
        [Parameter(Mandatory)][string]$Type,
        [ValidateSet([TechnologyArea])]
        [Parameter(Mandatory)][string]$TechnologyFocusArea,
        [ValidateSet([TargetAudience])]
        [Parameter(Mandatory)][string[]]$TargetAudience,
        [DateTime]$Date,
        [DateTime]$EndDate,
        [int]$Quantity,
        [int]$Reach
    )
    return [MvpActivity]@{
        userProfileId       = (Get-MvpContext).MvpProfile.id
        tenant              = (Get-MvpContext).Tenant
        title               = $Title
        description         = $Description
        date                = $Date ?? (Get-Date)
        dateEnd             = $EndDate ?? (Get-Date)
        activityTypeName    = $Type
        technologyFocusArea = $TechnologyFocusArea
        quantity            = $Quantity ?? 1
        reach               = $Reach ?? 0
        url                 = 'https://mvp.microsoft.com'
        targetAudience      = $TargetAudience
    }
}

filter Remove-MvpActivity {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High', DefaultParameterSetName = 'Activity')]
    param(
        [Parameter(ParameterSetName = 'Activity', Mandatory, ValueFromPipeline)]
        [MvpActivity]$MvpActivity,

        [Parameter(ParameterSetName = 'SearchResult', Mandatory, ValueFromPipeline)]
        [MvpSearchResult]$SearchResult
    )

    #TODO: Should probably use an interface or a base class
    $Activity = $SearchResult ?? $MvpActivity

    if (-not $PSCmdlet.ShouldProcess("$($Activity.id): $($Activity.title)", 'Delete Activity')) {
        return
    }

    try {
        $response = Invoke-MvpRestMethod -Endpoint "Activities/$($Activity.id)" -Method 'DELETE'
        if ($response -notmatch $Activity.id) {
            throw "Expected $($Activity.id) to be deleted but $response was returned. This is probably a bug."
        }
    } catch {
        $PSItem | ThrowCmdletError
    }
}

filter ThrowCmdletError {
    param(
        [string]$Message,
        [Parameter(ValueFromPipeline)][ErrorRecord]$ErrorRecord,
        $ThisCmdlet = $(Get-Variable -Scope 1 -Name PSCmdlet -ValueOnly)
    )
    if ($Message) {
        $ErrorRecord.ErrorDetails = $Message
    }
    $ThisCmdlet.ThrowTerminatingError($ErrorRecord)
}