WorkingTimeCmdlets.psm1

# ---------------------------------------------------------------------------
# WorkingTimeCmdlets — PowerShell module for Microsoft Graph workingTimeSchedule
# ---------------------------------------------------------------------------

$script:GraphBase = "https://graph.microsoft.com/v1.0"
$script:UserIdCache = @{}

# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------

function Assert-GraphConnection {
    <#
    .SYNOPSIS
        Validates that a Microsoft Graph connection exists with application permissions.
    #>

    $ctx = Get-MgContext -ErrorAction SilentlyContinue
    if (-not $ctx) {
        throw "Not connected to Microsoft Graph. Run Connect-MgGraph first. See README for setup details."
    }
    if ($ctx.AuthType -ne "AppOnly") {
        throw "The workingTimeSchedule API only supports application permissions (delegated is not supported). Connect using client credentials: Connect-MgGraph -ClientId <id> -TenantId <id> -ClientSecretCredential <cred>. See README for details."
    }
}

function Invoke-WTGraphRequest {
    <#
    .SYNOPSIS
        Makes a Graph API call with standardized error handling and retry logic.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Method,
        [Parameter(Mandatory)][string]$Uri,
        [switch]$Allow404
    )

    $maxRetries = 3
    for ($attempt = 1; $attempt -le $maxRetries; $attempt++) {
        try {
            $params = @{
                Method      = $Method
                Uri         = $Uri
                OutputType  = "Json"
            }
            $response = Invoke-MgGraphRequest @params
            if ($response -is [string]) {
                return $response | ConvertFrom-Json
            }
            return $response
        }
        catch {
            $status = $null
            $graphMessage = $null

            # Extract status code and message from the Graph error
            if ($_.Exception.Response) {
                $status = [int]$_.Exception.Response.StatusCode
            }
            if ($_.ErrorDetails.Message) {
                try {
                    $errBody = $_.ErrorDetails.Message | ConvertFrom-Json
                    $graphMessage = $errBody.error.message
                }
                catch {
                    $graphMessage = $_.ErrorDetails.Message
                }
            }

            if ($status -eq 404 -and $Allow404) {
                return $null
            }

            # Retry on 429 (throttled) or 503 (service unavailable)
            if ($status -in @(429, 503) -and $attempt -lt $maxRetries) {
                $delay = [math]::Pow(2, $attempt)
                # Honor Retry-After header if present
                if ($_.Exception.Response.Headers) {
                    $retryAfter = $_.Exception.Response.Headers['Retry-After']
                    if ($retryAfter) {
                        $parsedDelay = 0
                        if ([int]::TryParse($retryAfter, [ref]$parsedDelay)) {
                            $delay = [math]::Max($parsedDelay, 1)
                        }
                    }
                }
                Write-Warning "Graph API returned $status. Retrying in ${delay}s (attempt $attempt of $maxRetries)..."
                Start-Sleep -Seconds $delay
                continue
            }

            if ($status -eq 403) {
                throw "Access denied ($Uri). Ensure your app registration has the required permissions with admin consent:`n - Schedule-WorkingTime.ReadWrite.All (appRoleId: 0b21c159-dbf4-4dbb-a6f6-490e412c716e)`n - User.Read.All (appRoleId: df021288-bdef-4463-88db-98f22de89214)`nGraph error: $graphMessage"
            }

            if ($status -eq 404) {
                throw "Not found ($Uri). Verify the user ID or UPN is correct. Graph error: $graphMessage"
            }

            if ($status -eq 429) {
                throw "Graph API throttled after $maxRetries attempts ($Uri). Try again later or reduce batch size."
            }

            throw "Graph API error ($Method $Uri): $($_.Exception.Message) $graphMessage"
        }
    }
}

function Resolve-UserIdentifier {
    <#
    .SYNOPSIS
        Resolves a UPN or object ID to an Entra object ID (GUID).
        The workingTimeSchedule API requires a GUID — UPNs are not accepted.
        Results are cached for the session to reduce API calls in pipelines.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$UserId
    )

    # If it's already a GUID, return as-is
    if ($UserId -match '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$') {
        return $UserId
    }

    # Check cache first
    $cacheKey = $UserId.ToLowerInvariant()
    if ($script:UserIdCache.ContainsKey($cacheKey)) {
        return $script:UserIdCache[$cacheKey]
    }

    # Resolve UPN to object ID via Graph
    try {
        $response = Invoke-MgGraphRequest -Method GET -Uri "$script:GraphBase/users/$([Uri]::EscapeDataString($UserId))?`$select=id" -OutputType Json
        if ($response -is [string]) {
            $response = $response | ConvertFrom-Json
        }
        $script:UserIdCache[$cacheKey] = $response.id
        return $response.id
    }
    catch {
        throw "Could not resolve user '$UserId'. Verify the UPN or object ID is correct. $_"
    }
}

# ---------------------------------------------------------------------------
# Public cmdlets
# ---------------------------------------------------------------------------

function Get-WorkingTimeSchedule {
    <#
    .SYNOPSIS
        Gets the working time schedule for a user.
 
    .DESCRIPTION
        Retrieves the workingTimeSchedule resource for the specified user from
        Microsoft Graph. Returns a status of "NotConfigured" if no
        schedule exists (404).
 
        Requires an app-only Graph connection with Schedule-WorkingTime.ReadWrite.All.
 
    .PARAMETER UserId
        The user's UPN (user@contoso.com) or Entra object ID.
 
    .EXAMPLE
        Get-WorkingTimeSchedule -UserId "user@contoso.com"
 
    .EXAMPLE
        "user1@contoso.com", "user2@contoso.com" | Get-WorkingTimeSchedule
 
    .EXAMPLE
        Get-MgUser -Filter "department eq 'Retail'" | Get-WorkingTimeSchedule
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias("UserPrincipalName", "Id")]
        [string]$UserId
    )

    begin { Assert-GraphConnection }

    process {
        try {
            $objectId = Resolve-UserIdentifier -UserId $UserId
            $result = Invoke-WTGraphRequest -Method GET -Uri "$script:GraphBase/users/$objectId/solutions/workingTimeSchedule" -Allow404

            if ($null -eq $result) {
                [PSCustomObject]@{
                    UserId = $UserId
                    Status = "NotConfigured"
                    Id     = $null
                }
            }
            else {
                [PSCustomObject]@{
                    UserId = $UserId
                    Status = "Configured"
                    Id     = $result.id
                }
            }
        }
        catch {
            Write-Error -ErrorRecord $_
        }
    }
}

function Start-WorkingTime {
    <#
    .SYNOPSIS
        Signals the start of working hours for a user.
 
    .DESCRIPTION
        Sends the startWorkingTime signal to Microsoft Graph for the specified user.
        This triggers Intune app protection policies associated with the start of
        working hours (on-shift).
 
        Does not configure or create a schedule — it sends an instant signal.
 
        Requires an app-only Graph connection with Schedule-WorkingTime.ReadWrite.All.
 
    .PARAMETER UserId
        The user's UPN (user@contoso.com) or Entra object ID.
 
    .EXAMPLE
        Start-WorkingTime -UserId "user@contoso.com"
 
    .EXAMPLE
        "user1@contoso.com", "user2@contoso.com" | Start-WorkingTime
 
    .EXAMPLE
        Get-MgUser -Filter "department eq 'Retail'" | Start-WorkingTime
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias("UserPrincipalName", "Id")]
        [string]$UserId
    )

    begin { Assert-GraphConnection }

    process {
        try {
            if ($PSCmdlet.ShouldProcess($UserId, "Start working time")) {
                $objectId = Resolve-UserIdentifier -UserId $UserId
                Invoke-WTGraphRequest -Method POST -Uri "$script:GraphBase/users/$objectId/solutions/workingTimeSchedule/startWorkingTime" | Out-Null

                [PSCustomObject]@{
                    UserId = $UserId
                    Action = "StartWorkingTime"
                    Result = "Accepted"
                }
            }
        }
        catch {
            Write-Error -ErrorRecord $_
        }
    }
}

function Stop-WorkingTime {
    <#
    .SYNOPSIS
        Signals the end of working hours for a user.
 
    .DESCRIPTION
        Sends the endWorkingTime signal to Microsoft Graph for the specified user.
        This triggers Intune app protection policies associated with the end of
        working hours (off-shift).
 
        Does not configure or create a schedule — it sends an instant signal.
 
        Requires an app-only Graph connection with Schedule-WorkingTime.ReadWrite.All.
 
    .PARAMETER UserId
        The user's UPN (user@contoso.com) or Entra object ID.
 
    .EXAMPLE
        Stop-WorkingTime -UserId "user@contoso.com"
 
    .EXAMPLE
        "user1@contoso.com", "user2@contoso.com" | Stop-WorkingTime
 
    .EXAMPLE
        Get-MgUser -Filter "department eq 'Retail'" | Stop-WorkingTime
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias("UserPrincipalName", "Id")]
        [string]$UserId
    )

    begin { Assert-GraphConnection }

    process {
        try {
            if ($PSCmdlet.ShouldProcess($UserId, "Stop working time")) {
                $objectId = Resolve-UserIdentifier -UserId $UserId
                Invoke-WTGraphRequest -Method POST -Uri "$script:GraphBase/users/$objectId/solutions/workingTimeSchedule/endWorkingTime" | Out-Null

                [PSCustomObject]@{
                    UserId = $UserId
                    Action = "StopWorkingTime"
                    Result = "Accepted"
                }
            }
        }
        catch {
            Write-Error -ErrorRecord $_
        }
    }
}