Functions/Get-SchoolScheduleMeeting.ps1

function Get-SchoolScheduleMeeting
{
    <#
        .LINK
        https://github.com/Sekers/SKYAPI/wiki
 
        .LINK
        Endpoint: https://developer.sky.blackbaud.com/api#api=school&operation=V1SchedulesMeetingsGet
         
        .SYNOPSIS
        Education Management School API - Returns a list of section meetings for a given date.
 
        .DESCRIPTION
        Education Management School API - Returns a list of section meetings for a given date.
        When end_date is supplied, a range of meetings between the given dates is returned.
        If end_date is not supplied, Get-SchoolScheduleMeeting defaults to 30 days from start_date.
 
        Additional Notes:
          - Returned meeting start & end times are in UTC DateTime format.
          - Returned meeting date is the date of the meeting in the School Time Zone as specified at https://[school_domain_here].myschoolapp.com/app/core#demographics.
          - Does not support the "show_time_for_current_date" request parameter. Setting it stamps the CURRENT day's UTC offset on every meeting, so a meeting in the other half of the year is reported an hour off (e.g., a January 08:30 EST class comes back as 08:30-04:00 when requested during EDT). If you need to convert time zones or DST, use PowerShell.
 
        .PARAMETER start_date
        Required. Start date of events you want returned. Use ISO-8601 date format (e.g., 2022-04-01).
        .PARAMETER end_date
        End date of events you want returned. Use ISO-8601 date format (2022-04-08).
        If not specified, defaults to 30 days from start_date.
        .PARAMETER offering_types
        Can take a single or multiple values as a comma-delimited string of integers (defaults to 1 'Academics').
        Supports the following offering types (use Get-SchoolOfferingType to get a list of all offering types):
            Academics: 1
            Activities: 2
            Advisory: 3
            Athletics: 9
        .PARAMETER section_ids
        comma-delimited list of integer values for the section identifiers to return. By default the route returns all sections.
        .PARAMETER last_modified
        Filters meetings to sections that were modified on or after the date provided. Use ISO-8601 date format (e.g., 2022-04-01).
        .PARAMETER SchoolTimeZoneId
        Indicates the School Time Zone as specified at https://[school_domain_here].myschoolapp.com/app/core#demographics.
        Get-SchoolScheduleMeeting will try to automatically pull the value from your school environment,
        but if you receive an error, you may have to manually override it with a valid time zone.
        This is required because Blackbaud does not return accurate time zone information from this endpoint.
        A time zone Id, StandardName or DaylightName is accepted (e.g., 'Eastern Standard Time' or 'Eastern Daylight Time').
        Use 'Get-TimeZone -ListAvailable' to get a list of valid time zones.
        .PARAMETER IncludeRosters
        Adds the roster information for each meeting. This is a large amount of data, and adds additional API calls, so only use it if you need it.
        Each returned meeting gains a 'roster' property containing the full section & roster object for that meeting's section.
        Dropped members are not included.
 
        .EXAMPLE
        Get-SchoolScheduleMeeting -start_date '2022-11-01'
        .EXAMPLE
        Get-SchoolScheduleMeeting -start_date '2022-11-01' -end_date '2022-11-30' -offering_types '1,3'
        .EXAMPLE
        Get-SchoolScheduleMeeting -start_date '2022-11-01' | Where-Object -Property faculty_user_id -eq '3154032' | Sort-Object meeting_date, start_time
        .EXAMPLE
        $HashArguments = @{
            start_date = '2022-11-01'
            end_date = '2022-11-30'
            section_ids = '82426521, 93054528'
            last_modified = '2023-12-09'
            SchoolTimeZoneId = "Central Standard Time"
        }
        Get-SchoolScheduleMeeting @HashArguments
        .EXAMPLE
        # Include the roster for each meeting's section and list the enrolled members of the first meeting.
        $Meetings = Get-SchoolScheduleMeeting -start_date '2022-11-01' -end_date '2022-11-30' -offering_types '1,3' -IncludeRosters
        $Meetings[0].roster.roster |
            Select-Object -ExpandProperty user -Property @{n='is_leader';e={$_.leader.is_leader}}, @{n='is_head';e={$_.leader.is_head}}, @{n='is_faculty';e={$_.leader.is_faculty}} |
            Select-Object first_name, last_name, email, is_leader, is_head, is_faculty
        .EXAMPLE
        $Meetings = Get-SchoolScheduleMeeting -start_date '2022-11-01'
        foreach ($meeting in $Meetings)
        {
            "`n--- Meeting Group ---"
            $meeting.group_name
            "--- Meeting Date (School Environment Time Zone) ---"
            $meeting.meeting_date
            "--- Start & End (Local Time) ---"
            $meeting.start_time.ToLocalTime().DateTime # DateTime Kind of 'Local'
            $meeting.end_time.ToLocalTime().DateTime # DateTime Kind of 'Local'
            "--- Start & End (Pacific Standard Time) ---"
            [System.TimeZoneInfo]::ConvertTimeBySystemTimeZoneId($meeting.start_time, 'Pacific Standard Time') # DateTime Kind of 'Unspecified'
            [System.TimeZoneInfo]::ConvertTimeBySystemTimeZoneId($meeting.end_time, 'Pacific Standard Time') # DateTime Kind of 'Unspecified'
        }
    #>

    
    [cmdletbinding()]
    Param(
        [Parameter(
        Position=0,
        Mandatory=$true,
        ValueFromPipeline=$true,
        ValueFromPipelineByPropertyName=$true)]
        [string]$start_date,

        [Parameter(
        Position=1,
        ValueFromPipeline=$true,
        ValueFromPipelineByPropertyName=$true)]
        [string]$end_date,

        [Parameter(
        Position=2,
        ValueFromPipeline=$true,
        ValueFromPipelineByPropertyName=$true)]
        [string]$offering_types,

        [Parameter(
        Position=3,
        ValueFromPipeline=$true,
        ValueFromPipelineByPropertyName=$true)]
        [string]$section_ids,

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

        [Parameter(
        Position=5,
        ValueFromPipeline=$true,
        ValueFromPipelineByPropertyName=$true)]
        [string]$SchoolTimeZoneId = ((Get-SchoolTimeZone).timezone_name),

        [Parameter(
        Position=6,
        ValueFromPipeline=$true,
        ValueFromPipelineByPropertyName=$true)]
        [switch]$IncludeRosters
    )
       
    # Set the endpoints
    $endpoint = 'https://api.sky.blackbaud.com/school/v1/schedules/meetings'

    # Set the response field
    $ResponseField = "value"

    # Set the parameters
    # SchoolTimeZoneId and IncludeRosters drive local behavior and are not API fields.
    $parameters = Get-SKYAPIRequestParameter -BoundParameters $PSBoundParameters -Exclude 'SchoolTimeZoneId','IncludeRosters'

    # IMPORTANT NOTE: NO SPACES ALLOWED BETWEEN VALUES FOR 'offering_types' STRING!!!! (e.g., "1,3" is the correct way, NOT "1, 3")
    # It will still process the query if there is a string, but only return results for the first value.
    # Remove spaces from 'offering_types' string if included in a comma-separated list.
    if ($parameters -contains 'offering_types')
    {
        $parameters.Remove('offering_types') | Out-Null
        $parameters.Add('offering_types',$($offering_types.Replace(' ','')))
    }

    # Convert SchoolTimeZoneId to a TimeZoneInfo object. Check for a match on Id, then StandardName, then
    # DaylightName. This is the only validation the value gets: a ValidateScript attribute would not run on
    # the Get-SchoolTimeZone default, since PowerShell validates only values that are actually bound, so
    # doing it here is what gives an explicitly passed value and the looked-up default the same treatment
    # and the same error. It also runs before any API request below, so an unmatched zone costs nothing.
    $SystemTimeZones = Get-TimeZone -ListAvailable
    $SchoolTimeZone = $SystemTimeZones | Where-Object -Property Id -EQ $SchoolTimeZoneId
    if ([string]::IsNullOrEmpty($SchoolTimeZone))
    {
        $SchoolTimeZone = $SystemTimeZones | Where-Object -Property StandardName -EQ $SchoolTimeZoneId
    }
    if ([string]::IsNullOrEmpty($SchoolTimeZone))
    {
        $SchoolTimeZone = $SystemTimeZones | Where-Object -Property DaylightName -EQ $SchoolTimeZoneId
    }
    if ([string]::IsNullOrEmpty($SchoolTimeZone))
    {
        throw "Unable to match the school time zone `"$SchoolTimeZoneId`" to a time zone on this system (checked Id, StandardName & DaylightName). Use 'Get-TimeZone -ListAvailable' to get a list of valid time zones."
    }
    $SchoolTimeZone = @($SchoolTimeZone)[0] # A StandardName/DaylightName match can return more than one zone; use the first.

    # Get the SKY API subscription key
    $sky_api_config = Get-SKYAPIConfig -ConfigPath $sky_api_config_file_path
    $sky_api_subscription_key = $sky_api_config.api_subscription_key

    # Grab the security tokens
    $AuthTokensFromFile = Get-SKYAPIAuthTokensFromFile

    # Validate Start Date String
    try {$null = [datetime]$start_date} catch
    {
        throw $_
    }

    # If the 'end_date' parameter doesn't exist, then set it to 30 days ahead (31 days TOTAL including start date), which is the max days ahead allowed per call.
    # It is supposed to default to 30 days, but it doesn't work correctly unless you specify an end date (at least in the beta).
    # Also, if you put in a larger time limit than 30 days, it sometimes does 31 days or something like that. It's really dumb.
    [int]$IterationRangeInDays = 30
    if ($null -eq $end_date -or $end_date -eq '' -or $end_date -eq 0)
    {
        $end_date = (([DateTime]$start_date).AddDays($IterationRangeInDays)).ToString('yyyy-MM-dd')
    }
    
    # Validate End Date String
    try {$null = [datetime]$end_date} catch
    {
        throw $_
    }

    # Validate that the start date is not after the end date.
    if ([datetime]$start_date -gt [datetime]$end_date)
    {
        throw "start_date ($start_date) cannot be after end_date ($end_date)."
    }

    # Initialize Variables
    $response = [System.Collections.Generic.List[Object]]::new()
    $DateRangeEnd = [DateTime]$end_date
    $DateIterationStart = [DateTime]$start_date
    $DateIterationEnd = $DateIterationStart.AddDays($IterationRangeInDays)
    $FinalIteration = $false

    # Iterate
    do
    {
        # Don't go beyond the final end date
        if ($DateIterationEnd -ge $DateRangeEnd)
        {
            $DateIterationEnd = $DateRangeEnd
            $FinalIteration = $true
        }
        
        # Remove the 'start_date' and 'end_date' parameters.
        $parameters.Remove('start_date') | Out-Null
        $parameters.Remove('end_date') | Out-Null

        # Add the parameters back in with the correct iteration values
        $parameters.Add('start_date',$DateIterationStart.ToString('yyyy-MM-dd'))
        $parameters.Add('end_date',$DateIterationEnd.ToString('yyyy-MM-dd'))

        # Get the data. On PS Core, ConvertFrom-Json turns any "o"-formatted (roundtrip) date/time string, or a
        # prefix of one down to the seconds part, into a [datetime]. The offset this endpoint sends is
        # unreliable (see the re-anchoring below), so the raw JSON is parsed with those values left as strings.

        if ($PSVersionTable.PSEdition -EQ 'Desktop')
        {
            $response_objects = Get-SKYAPIUnpagedEntity -url $endpoint -api_key $sky_api_subscription_key -authorisation $AuthTokensFromFile -params $parameters -response_field $ResponseField
        }
        else
        {
            $response_raw = Get-SKYAPIUnpagedEntity -url $endpoint -api_key $sky_api_subscription_key -authorisation $AuthTokensFromFile -params $parameters -response_field $ResponseField -ReturnRaw
            $response_objects = (ConvertFrom-JsonWithoutDateTimeDeserialization -InputObject $response_raw).$ResponseField
        }

        # Only return a response to the list if there's data.
        if ($null -ne $response_objects)
        {
            foreach ($response_object in $response_objects)
            {
                $response.Add($response_object)
            }
        }

        # Increase Iteration Range
        $DateIterationStart = $DateIterationStart.AddDays($IterationRangeInDays + 1)
        $DateIterationEnd = $DateIterationEnd.AddDays($IterationRangeInDays + 1)
    }
    until($FinalIteration -eq $true)

    # Collect rosters if requested and build a lookup keyed by section ID.
    # Rosters are gathered after the meetings so we only pull rosters for the sections actually returned.
    # This is a large amount of data and adds additional API calls, which is why it's opt-in via -IncludeRosters.
    $RosterLookup = @{}
    if ($IncludeRosters -and $response.Count -gt 0)
    {
        # Default to Academics (1) when no offering types were specified, matching the meetings query default.
        $RosterOfferingTypes = if ([string]::IsNullOrWhiteSpace($offering_types)) { '1' } else { $offering_types }

        # Determine which school year(s) the requested date range overlaps so we pull rosters from each.
        $OverlappingSchoolYears = Get-SchoolYear | Where-Object {
            (([datetime]$_.begin_date) -le ([datetime]$end_date)) -and (([datetime]$_.end_date) -ge ([datetime]$start_date))
        }

        # Map each supported offering type to the function that retrieves its rosters.
        $RosterFunctionByOfferingType = @{
            '1' = 'Get-SchoolRoster'          # Academics
            '2' = 'Get-SchoolActivityRoster'  # Activities
            '3' = 'Get-SchoolAdvisoryRoster'  # Advisory
            '9' = 'Get-SchoolAthleticRoster'  # Athletics
        }

        foreach ($rosterOfferingType in ((($RosterOfferingTypes -replace '\s','') -split ',') | Where-Object {$_ -ne ''}))
        {
            $RosterFunction = $RosterFunctionByOfferingType[$rosterOfferingType]

            # Skip unsupported offering types.
            if ($null -eq $RosterFunction) { continue }

            # Distinct section IDs for this offering type from the returned meetings.
            [array]$SectionIdsForType = $response | Where-Object {[string]$_.offering_type.id -eq $rosterOfferingType} |
                Select-Object -ExpandProperty section_id -Unique
            if ($SectionIdsForType.Count -eq 0) { continue }

            # Batch the section IDs by URL length rather than a fixed count: pack as many IDs as
            # fit into a single 'section_ids' value, only splitting when the query would get too long.
            # In most cases this is a single batch (one API call per offering type & school year).
            # $MaxSectionIdsLength is a conservative budget for the 'section_ids' query value that keeps
            # the total request URL well under the ~2048-character limit imposed by many servers/proxies.
            [int]$MaxSectionIdsLength = 1800
            $SectionIdBatches = [System.Collections.Generic.List[string]]::new()
            $CurrentBatch = [System.Text.StringBuilder]::new()
            foreach ($SectionId in $SectionIdsForType)
            {
                $SectionIdString = [string]$SectionId

                # Start a new batch if appending this ID (plus its comma separator) would exceed the budget.
                if ($CurrentBatch.Length -gt 0 -and ($CurrentBatch.Length + 1 + $SectionIdString.Length) -gt $MaxSectionIdsLength)
                {
                    $SectionIdBatches.Add($CurrentBatch.ToString())
                    $CurrentBatch = [System.Text.StringBuilder]::new()
                }

                if ($CurrentBatch.Length -gt 0) { [void]$CurrentBatch.Append(',') }
                [void]$CurrentBatch.Append($SectionIdString)
            }
            if ($CurrentBatch.Length -gt 0) { $SectionIdBatches.Add($CurrentBatch.ToString()) }

            foreach ($SchoolYear in $OverlappingSchoolYears)
            {
                foreach ($SectionIdBatch in $SectionIdBatches)
                {
                    # Call the appropriate roster function for this offering type and school year, passing the batched section IDs.
                    $SectionRosters = & $RosterFunction -school_year $SchoolYear.id -section_ids $SectionIdBatch

                    # Add the roster objects to the lookup keyed by section ID.
                    foreach ($SectionRoster in $SectionRosters)
                    {
                        $RosterLookup[[string]$SectionRoster.section.id] = $SectionRoster
                    }
                }
            }
        }
    }

    # Extracts the wall-clock time-of-day (HH:mm:ss[.fff]) from an API start/end value. Blackbaud returns
    # the correct School-local time-of-day but with an unreliable UTC offset, so we keep the time and
    # re-anchor it to meeting_date in the School time zone (below), which also gets DST right. This accepts
    # either offset sign (e.g. -06:00 or +10:00, so non-US/positive-offset schools work). If the value ever
    # comes back as 'Z'/UTC or any other shape, the "time-of-day is School-local" assumption no longer holds,
    # so throw instead of silently producing wrong times.
    $ExtractSchoolLocalTime = {
        param ([string]$ApiDateTime, [string]$FieldName)
        if ($ApiDateTime -match 'T(\d{2}:\d{2}:\d{2}(?:\.\d+)?)[+-]\d{2}:\d{2}$')
        {
            return $Matches[1]
        }
        throw "Unexpected '$FieldName' format from the SKY API schedules/meetings endpoint: '$ApiDateTime'. Expected a time with a UTC offset (e.g. ...T14:20:00-06:00). Blackbaud may have changed the endpoint; review the date massaging in Get-SchoolScheduleMeeting."
    }

    $response = foreach ($meeting in $response)
    {
        # Keep the date portion only; the time that comes with meeting_date carries no information.
        $meeting_date = ($meeting.meeting_date -split "T")[0]

        # Pair each School-local time-of-day with that date. Parsed with the invariant culture so the result
        # does not depend on the host's regional settings.
        $StartLocal = [datetime]::Parse(
            [System.String]::Concat($meeting_date, "T", (& $ExtractSchoolLocalTime $meeting.start_time 'start_time')),
            [System.Globalization.CultureInfo]::InvariantCulture)
        $EndLocal = [datetime]::Parse(
            [System.String]::Concat($meeting_date, "T", (& $ExtractSchoolLocalTime $meeting.end_time 'end_time')),
            [System.Globalization.CultureInfo]::InvariantCulture)

        # Both times are anchored to meeting_date, so a meeting running past midnight would otherwise end
        # before it starts. An end earlier than its start only makes sense as the following day.
        if ($EndLocal -lt $StartLocal)
        {
            $EndLocal = $EndLocal.AddDays(1)
        }

        # If the start never happened (the spring-forward gap), the whole meeting moves forward with the
        # clock, so the end has to move by the same amount and the meeting keeps its nominal length. Shifting
        # only the start would let it overtake an end that was already valid: a 02:30-03:00 meeting on a
        # transition day came back as 07:30Z to 07:00Z, a negative duration. Both ends are moved here rather
        # than left to the per-value conversion below, which cannot see that the two belong together.
        if ($SchoolTimeZone.IsInvalidTime($StartLocal))
        {
            $DaylightJump = Get-SKYAPIDaylightJump -OnDate $StartLocal -TimeZone $SchoolTimeZone
            $StartLocal = $StartLocal.Add($DaylightJump)
            $EndLocal = $EndLocal.Add($DaylightJump)
        }

        # Convert as School time, so the UTC result lands on the right side of a daylight saving boundary.
        # The helper also makes a best guess on the two readings a year that are not a single instant, rather
        # than throwing (spring forward) or silently taking the later one (fall back).
        $start_time = ConvertTo-SKYAPIUtcFromSchoolLocal -SchoolLocalDateTime $StartLocal -SchoolTimeZone $SchoolTimeZone
        $end_time = ConvertTo-SKYAPIUtcFromSchoolLocal -SchoolLocalDateTime $EndLocal -SchoolTimeZone $SchoolTimeZone

        $meeting.start_time = Get-Date $start_time
        $meeting.end_time = Get-Date $end_time
        $meeting.meeting_date = $meeting_date # Left as a string so a client time zone cannot shift the date.

        # Attach the roster (full section & roster object) for this meeting's section, if collected.
        if ($IncludeRosters)
        {
            $meeting | Add-Member -NotePropertyName 'roster' -NotePropertyValue $RosterLookup[[string]$meeting.section_id] -Force
        }

        $meeting
    }

    return $response
}