Functions/Update-SchoolUserAddress.ps1

function Update-SchoolUserAddress
{
    <#
        .LINK
        https://github.com/Sekers/SKYAPI/wiki
 
        .LINK
        Endpoint: https://developer.sky.blackbaud.com/api#api=school&operation=V1UsersByUser_idAddressesByAddress_idPatch
 
        .SYNOPSIS
        Updates an address record for a user and returns the ID of the address updated.
 
        .DESCRIPTION
        Updates an address record for a user and returns the ID of the address updated.
 
        The API requires user_id, address_id, type_id, and line_one in every request. Deleting or clearing an
        optional field requires fields_to_delete.
 
        This endpoint merges rather than replaces: fields you do not supply keep their current values.
        Confirmed by test against a developer tenant - an update sending only type_id and line_one left
        city, state, postal_code, country and the remaining fields untouched.
 
        Requires at least one of the following roles in the Education Management system:
          - SKY API Data Sync
          - Platform Manager
          - Contact Card Manager
 
        .PARAMETER user_id
        Required. The ID of the user whose address is being updated.
        .PARAMETER address_id
        Required. The ID of the address to update.
        .PARAMETER type_id
        Required. The type ID of the specified address. The type ID corresponds with the type of address
        (ex. Business/College, Home, Summer). Use Get-SchoolUserAddressType to get a list of address types.
        .PARAMETER line_one
        Required. The first address line.
        .PARAMETER city
        The city for the address.
        .PARAMETER country
        The full country name.
        .PARAMETER line_two
        The second address line.
        .PARAMETER line_three
        The third address line.
        .PARAMETER mailing_address
        Set to true if the user accepts mail at this address.
        .PARAMETER postal_code
        The postal code for the address.
        .PARAMETER primary
        Set to true if this is the user's primary address.
        .PARAMETER province
        The province for the address.
        .PARAMETER region
        The region for the address.
        .PARAMETER state
        The state for the address.
        .PARAMETER links
        Address type links for relationships to the user. Each item may contain type_id, primary, shared,
        shared_relationship, shared_user, type, and user_id.
        .PARAMETER salutations
        Address salutations. Provide a hashtable or PSCustomObject containing informal, formal, or household.
        Note: salutations can be set but not cleared through this API. Supplying an empty value is ignored, and
        neither 'salutations' nor 'salutations.informal' works in fields_to_delete - the API accepts both and
        returns success without changing anything (confirmed by test against a developer tenant). Use -Validate
        if you need that silent no-op reported rather than assumed to have worked; clear them in the web UI.
        .PARAMETER fields_to_delete
        Field names to clear. A cleared field overrides a value supplied for the same field. Required fields,
        Boolean fields, and fields without a blank option are ignored by the API. Verified working on the
        top-level string fields (e.g. 'line_two', 'line_three'); see -salutations for the one known exception.
        .PARAMETER Validate
        Re-read the address after the update and confirm that every supplied field took effect. The read-back requires
        one additional API call per updated address. If IncludeUpdatedObject is also specified, both switches use that
        same call, so together they still make only one additional API call. The function throws if the address cannot
        be read or a supplied value does not match.
 
        Validation stops at the first address that fails, so later addresses piped into the same call are not updated.
        Addresses processed BEFORE the failure have already been updated at the API and are NOT rolled back; the error
        names them ("Already updated before this failure: ...") so you can tell where the batch stopped. Addresses still
        upstream in the pipeline cannot be listed, because this function does not know what is coming. If the update
        request itself fails, the error also says what that means for the address it was attempting. An HTTP 4xx is a
        definite rejection: the API evaluated the request and refused it, so that address was not changed. Anything
        else, a 5xx or no response at all, leaves the outcome unknown, because the API may have applied the change
        before failing, so that address is reported as neither updated nor untouched and should be checked before
        retrying.
        Note that the failure is terminating, so assigning the result (e.g. $r = ... | Update-SchoolUserAddress) discards
        the responses for the addresses that did succeed. Stream the output instead if you need to keep them.
        .PARAMETER IncludeUpdatedObject
        Re-read the updated address and attach it to the returned address ID as an UpdatedObject property. This can be
        used with or without Validate. The read-back requires one additional API call per updated address. If Validate
        is also specified, both switches use that same call, so together they still make only one additional API call.
        The function throws if the address cannot be read.
 
        .EXAMPLE
        Update-SchoolUserAddress -user_id 3156271 -address_id 4708014 -type_id 1005 `
            -line_one '129 Huntington Drive' -city 'Chicago' -state 'IL' -postal_code '60601'
 
        .EXAMPLE
        Update-SchoolUserAddress -user_id 3156271 -address_id 4708014 -type_id 1005 `
            -line_one '129 Huntington Drive' -fields_to_delete 'line_two','line_three' -Validate
 
        .EXAMPLE
        Update-SchoolUserAddress -user_id 3156271 -address_id 4708014 -type_id 1005 `
            -line_one '129 Huntington Drive' -city 'Chicago' -Validate -IncludeUpdatedObject
 
        .OUTPUTS
        Returns the address ID from the update endpoint. With IncludeUpdatedObject, the same ID gains an
        UpdatedObject property containing the address returned by Get-SchoolUserAddress.
    #>


    [CmdletBinding()]
    Param(
        [Parameter(
        Position=0,
        Mandatory=$true,
        ValueFromPipeline=$true,
        ValueFromPipelineByPropertyName=$true)]
        [int]$user_id,

        [Parameter(
        Position=1,
        Mandatory=$true,
        ValueFromPipelineByPropertyName=$true)]
        [int]$address_id,

        [Parameter(
        Position=2,
        Mandatory=$true,
        ValueFromPipelineByPropertyName=$true)]
        [int]$type_id,

        [Parameter(
        Position=3,
        Mandatory=$true,
        ValueFromPipelineByPropertyName=$true)]
        [ValidateNotNullOrEmpty()]
        [string]$line_one,

        [Parameter(
        Position=4,
        ValueFromPipelineByPropertyName=$true)]
        [string]$city,

        [Parameter(
        Position=5,
        ValueFromPipelineByPropertyName=$true)]
        [string]$country,

        [Parameter(
        Position=6,
        ValueFromPipelineByPropertyName=$true)]
        [string]$line_two,

        [Parameter(
        Position=7,
        ValueFromPipelineByPropertyName=$true)]
        [string]$line_three,

        [Parameter(
        Position=8,
        ValueFromPipelineByPropertyName=$true)]
        [bool]$mailing_address,

        [Parameter(
        Position=9,
        ValueFromPipelineByPropertyName=$true)]
        [string]$postal_code,

        [Parameter(
        Position=10,
        ValueFromPipelineByPropertyName=$true)]
        [bool]$primary,

        [Parameter(
        Position=11,
        ValueFromPipelineByPropertyName=$true)]
        [string]$province,

        [Parameter(
        Position=12,
        ValueFromPipelineByPropertyName=$true)]
        [string]$region,

        [Parameter(
        Position=13,
        ValueFromPipelineByPropertyName=$true)]
        [string]$state,

        [Parameter(
        Position=14,
        ValueFromPipelineByPropertyName=$true)]
        [object[]]$links,

        [Parameter(
        Position=15,
        ValueFromPipelineByPropertyName=$true)]
        [object]$salutations,

        [Parameter(
        Position=16,
        ValueFromPipelineByPropertyName=$true)]
        [AllowEmptyCollection()]
        [string[]]$fields_to_delete,

        [Parameter(
        ValueFromPipelineByPropertyName=$true)]
        [switch]$Validate,

        [Parameter(
        ValueFromPipelineByPropertyName=$true)]
        [switch]$IncludeUpdatedObject
    )

    begin
    {
        $endpoint = 'https://api.sky.blackbaud.com/school/v1/users/'

        $sky_api_config = Get-SKYAPIConfig -ConfigPath $sky_api_config_file_path
        $sky_api_subscription_key = $sky_api_config.api_subscription_key

        # How -Validate compares each request field against the re-read address. Only used when -Validate is passed.
        # Deliberately absent: 'id' and 'user_id'. Both are added to every request body below because the API requires
        # them, but neither is caller-supplied content, so validating them is at best redundant and at worst wrong.
        # 'id' is already how the read-back is selected, and an address can be shared between users (see AddressShare
        # and the shared_user/shared_relationship link fields), so a shared address can legitimately read back a
        # different user_id than the one the update was addressed to - which would fail a write that actually worked.
        $ValidationFieldSpec = [ordered]@{
            type_id         = @{Kind = 'Scalar'}
            line_one        = @{Kind = 'Scalar'}
            city            = @{Kind = 'Scalar'}
            country         = @{Kind = 'Scalar'}
            line_two        = @{Kind = 'Scalar'}
            line_three      = @{Kind = 'Scalar'}
            mailing_address = @{Kind = 'Scalar'}
            postal_code     = @{Kind = 'Scalar'}
            primary         = @{Kind = 'Scalar'}
            province        = @{Kind = 'Scalar'}
            region          = @{Kind = 'Scalar'}
            state           = @{Kind = 'Scalar'}
            # Only the writable link fields are compared. 'shared' is documented Read Only, and 'type',
            # 'shared_user' and 'shared_relationship' are server-derived display values. Since -links binds by
            # property name, the natural round-trip (Get-SchoolUserAddress | Update-SchoolUserAddress) sends all
            # of them straight back, and comparing them would report the server's own values as a failed update.
            links           = @{Kind = 'ObjectArray'; MatchFields = @('user_id','type_id'); SubFields = [ordered]@{
                type_id             = @{Kind = 'Scalar'}
                primary             = @{Kind = 'Scalar'}
                user_id             = @{Kind = 'Scalar'}}}
            salutations     = @{Kind = 'Object'; SubFields = [ordered]@{
                informal  = @{Kind = 'Scalar'}
                formal    = @{Kind = 'Scalar'}
                household = @{Kind = 'Scalar'}}}
        }

        # Pipeline binding has not happened yet, so these are the parameters supplied on the command line.
        $CommandLineBoundParameter = @($PSBoundParameters.Keys)

        # Every address this invocation has successfully updated. This function takes one address per call,
        # so a batch is always a pipeline, which is why this lives in begin{} rather than process{}. A
        # failure is terminating, so without this the addresses already committed go unreported. There is no
        # matching "not processed" list: records still upstream in a pipeline are unknowable from here.
        $CompletedAddresses = [System.Collections.Generic.List[string]]::new()
    }

    process
    {
        # Appended to every failure below. Computed here because nothing in this record has completed yet, so
        # the list is exactly what was committed before it. Empty on the first record, where it says nothing.
        $BatchBoundary = if ($CompletedAddresses.Count -gt 0)
        {
            [Environment]::NewLine + "Already updated before this failure: $($CompletedAddresses -join '; ')."
        }
        else {''}

        $AuthTokensFromFile = Get-SKYAPIAuthTokensFromFile

        # Build a body using only fields supplied by this pipeline record. The two URL/body IDs are added with
        # the names required by the API. Validate and IncludeUpdatedObject are local control switches rather than
        # request fields.
        $SuppliedParameter = Get-SKYAPISuppliedParameterName -BoundParameters $PSBoundParameters `
                             -CommandLineBound $CommandLineBoundParameter -PipelineItem $PSItem -Invocation $MyInvocation
        $parameters = Get-SKYAPIRequestParameter -BoundParameters $PSBoundParameters `
                      -Exclude 'user_id','address_id','Validate','IncludeUpdatedObject' `
                      -SuppliedNames $SuppliedParameter -As Body
        $parameters['id'] = $address_id
        $parameters['user_id'] = $user_id

        $endUrl = "/addresses/$address_id"

        # The write itself. Wrapped because this is the only failure whose effect on the record cannot be read
        # off the code path: every other failure below happens after a successful write, whereas here it
        # depends on what came back. The catch classifies it, since a refused request and a lost one need
        # opposite advice.
        try
        {
            $response = Update-SKYAPIEntity -uid $user_id -url $endpoint -endUrl $endUrl `
                        -api_key $sky_api_subscription_key -authorisation $AuthTokensFromFile -params $parameters
        }
        catch
        {
            # Only a 4xx is a definite "nothing happened": the server evaluated the request and refused it. A
            # 5xx means the server accepted the request and then failed, which says nothing about whether the
            # write landed first, and 502/504 in particular mean a proxy lost the upstream answer, no
            # different from getting no response at all. Those are retried with backoff before surfacing
            # here, so the write may have been attempted several times. Anything that is not a 4xx is
            # therefore reported as uncertain, which is also the safe default for a status this code did not
            # anticipate. Get-SKYAPIErrorStatusCode returns $null when no response arrived.
            $WriteStatusCode = Get-SKYAPIErrorStatusCode $_
            $WriteOutcome = if ($null -ne $WriteStatusCode -and $WriteStatusCode -ge 400 -and $WriteStatusCode -lt 500)
            {
                "The API rejected the update to address $address_id for user $user_id (HTTP $WriteStatusCode), so that address was not changed."
            }
            elseif ($null -ne $WriteStatusCode)
            {
                "The update to address $address_id for user $user_id failed with HTTP $WriteStatusCode, so whether the API applied it before failing is unknown; check that address before retrying."
            }
            else
            {
                "The update to address $address_id for user $user_id did not complete and no response was received, so whether the API applied it is unknown; check that address before retrying."
            }

            # The outcome line is always worth having, even for the first record: it names the address and says
            # whether anything happened to it. $BatchBoundary is appended only once something has been
            # committed. The original exception is kept as InnerException so nothing about it is lost.
            throw (New-Object System.Exception(
                ($_.Exception.Message + [Environment]::NewLine + $WriteOutcome + $BatchBoundary), $_.Exception))
        }

        $ReadBackRecord = $null
        if ($Validate -or $IncludeUpdatedObject)
        {
            try
            {
                $ReadBackRecord = @(Get-SchoolUserAddress -User_ID $user_id -ErrorAction Stop) |
                                  Where-Object {[string]$_.id -eq [string]$address_id} |
                                  Select-Object -First 1
            }
            catch
            {
                if ($Validate)
                {
                    throw "Update-SchoolUserAddress: address $address_id for user $user_id was updated, but the update could not be verified because Get-SchoolUserAddress failed: $($_.Exception.Message)$BatchBoundary"
                }

                throw "Update-SchoolUserAddress: address $address_id for user $user_id was updated, but the updated object could not be returned because Get-SchoolUserAddress failed: $($_.Exception.Message)$BatchBoundary"
            }

            if ($null -eq $ReadBackRecord -and -not $Validate)
            {
                throw "Update-SchoolUserAddress: address $address_id for user $user_id was updated, but the updated object could not be returned because the address could not be read back.$BatchBoundary"
            }
        }

        if ($Validate)
        {
            $ClearedFieldList = if ($parameters.ContainsKey('fields_to_delete')) { @($parameters['fields_to_delete']) } else { @() }
            $ValidationFindings = @(Confirm-SKYAPIWriteResult -Actual $ReadBackRecord -Expected $parameters `
                -FieldSpec $ValidationFieldSpec -ClearedFields $ClearedFieldList `
                -RecordDescription "address $address_id for user $user_id")

            if ($ValidationFindings.Count -gt 0)
            {
                $Mismatches = @($ValidationFindings | Where-Object {$_.Kind -eq 'Mismatch'})
                $Detail = ($ValidationFindings | ForEach-Object {" - $($_.Field): $($_.Reason)"}) -join [Environment]::NewLine

                if ($Mismatches.Count -gt 0)
                {
                    throw ("Update-SchoolUserAddress: address $address_id for user $user_id was updated, but validating it found $($ValidationFindings.Count) problem(s):" +
                        [Environment]::NewLine + $Detail + [Environment]::NewLine +
                        'Either the API ignored these values, or the fields are not visible to your account.' + $BatchBoundary)
                }

                throw ("Update-SchoolUserAddress: address $address_id for user $user_id was updated, but $($ValidationFindings.Count) field(s) could not be verified:" +
                    [Environment]::NewLine + $Detail + $BatchBoundary)
            }

            Write-Verbose "Validated address $address_id for user $user_id."
        }

        if ($IncludeUpdatedObject)
        {
            $response | Add-Member -MemberType NoteProperty -Name UpdatedObject -Value $ReadBackRecord
        }

        # This address is done. Recorded before emitting so a later record's failure can name it as committed.
        $CompletedAddresses.Add("address $address_id for user $user_id")

        $response
    }

    end {}
}