Private/FSvc.Tickets.ps1
|
# Ticket querying and triage helpers. # Named saved views. Defined once so the "self-assigned unresolved" and # "unassigned unresolved" query_hash strings cannot drift between commands. $script:FSvcViews = [ordered]@{ SelfAssigned = '[{"condition":"status","operator":"is_in","value":["0"],"type":"default"},{"condition":"responder_id","operator":"is_in","value":["0"],"type":"default"}]' Unassigned = '[{"condition":"status","operator":"is_in","value":["0"],"type":"default"},{"condition":"responder_id","operator":"is_in","value":["-1"],"type":"default"}]' } # The query_hash for a named view. function Get-FSvcViewQueryHash { param([Parameter(Mandatory)][ValidateSet('SelfAssigned', 'Unassigned')][string]$View) return $script:FSvcViews[$View] } # Ticket selection for a command: a raw query hash when given, otherwise a # named saved view. Owns the view -> query_hash mapping and the paging, so # callers only say which tickets they want. function Get-FSvcTargetTickets { param( [ValidateSet('SelfAssigned', 'Unassigned')][string]$View = 'SelfAssigned', [string]$QueryHash, [int]$PerPage = 100, [int]$MaxPages = 1000, [hashtable]$Config ) $hash = if ($QueryHash) { $QueryHash } else { Get-FSvcViewQueryHash -View $View } return Invoke-FSvcPagedQuery -Path "tickets" -ArrayKey "tickets" -MaxPages $MaxPages -Config $Config -BaseQuery @{ "order_by" = "created_at" "order_type" = "asc" "per_page" = $PerPage "query_hash" = $hash } } # One page of a ticket's conversations, newest first, normalised to the shared # view shape. HasNext reports whether the API holds more pages. function Get-FSvcConversationPage { param([int64]$TicketId, [int]$Page = 1, [int]$PerPage = 50, [hashtable]$Config) $query = @{ "order_by" = "created_at" "order_type" = "desc" "per_page" = $PerPage "page" = $Page } $data = (Invoke-FSvcGet -Path ("tickets/{0}/conversations" -f $TicketId) -Query $query -Config $Config) | ConvertFrom-FSvcJson $items = @(@($data.conversations) | Where-Object { $null -ne $_ } | ForEach-Object { ConvertTo-FSvcConversationView $_ }) return [pscustomobject]@{ Items = $items HasNext = [bool]$data.meta.has_next } } # Most recent conversation for a ticket - any kind, private note or public # reply - or $null when it has none. function Get-FSvcLatestConversation { param([int64]$TicketId, [hashtable]$Config) $page = Get-FSvcConversationPage -TicketId $TicketId -Page 1 -PerPage 1 -Config $Config return (@($page.Items) | Select-Object -First 1) } # Leading run of incoming (customer) messages before the first outgoing one. function Get-FSvcUnansweredCount { param([AllowEmptyCollection()][object[]]$Conversations) $count = 0 foreach ($c in $Conversations) { if ($c.Direction -eq 'incoming') { $count++ } else { break } } return $count } # Newest-first conversation views for a ticket, plus how many customer messages # the agent has not answered yet (the consecutive incoming run at the tail). # Pages only while a whole page is unanswered, so an answered thread costs one # request; MaxPages caps a pathological all-customer thread. function Get-FSvcTicketThread { param([int64]$TicketId, [hashtable]$Config, [int]$PerPage = 50, [int]$MaxPages = 5) $items = @() $unanswered = 0 $pageNumber = 1 while ($true) { $page = Get-FSvcConversationPage -TicketId $TicketId -Page $pageNumber -PerPage $PerPage -Config $Config $pageItems = @($page.Items) $items += $pageItems $incoming = Get-FSvcUnansweredCount -Conversations $pageItems $unanswered += $incoming if ($incoming -lt $pageItems.Count) { break } if (-not $page.HasNext) { break } if ($pageNumber -ge $MaxPages) { break } $pageNumber++ } return [pscustomobject]@{ Items = $items Latest = (@($items) | Select-Object -First 1) Unanswered = $unanswered } } # Normalises a raw API conversation (or the latest-conversation response) into # the single shape consumers use: Id, Author (nested user name else user_id), # UserId, Direction, At, Body (body_text preferred over body). function ConvertTo-FSvcConversationView { param([AllowNull()]$Conversation) if ($null -eq $Conversation) { return $null } $author = if ($Conversation.user -and $Conversation.user.name) { [string]$Conversation.user.name } else { [string]$Conversation.user_id } $direction = if ($Conversation.incoming) { 'incoming' } else { 'outgoing' } $body = if ($Conversation.body_text) { [string]$Conversation.body_text } elseif ($Conversation.body) { [string]$Conversation.body } else { '' } return [pscustomobject]@{ Id = $Conversation.id Author = $author UserId = [int64]$Conversation.user_id Direction = $direction At = ConvertTo-FSDateTimeOffset $Conversation.created_at Body = $body } } # The UTC offset evidenced by the tickets' own dates (planned_end_date # preferred, created_at fallback), or the fallback's offset. function Get-FSvcAccountOffset { param($Tickets, [datetimeoffset]$Fallback) foreach ($t in @($Tickets)) { $d = ConvertTo-FSDateTimeOffset $t.planned_end_date if ($null -ne $d) { return $d.Offset } } foreach ($t in @($Tickets)) { $d = ConvertTo-FSDateTimeOffset $t.created_at if ($null -ne $d) { return $d.Offset } } return $Fallback.Offset } # Renders a business-days number with one decimal, invariant culture. function Format-FSvcDays { param([double]$Days) $rounded = [math]::Round($Days, 1, [System.MidpointRounding]::AwayFromZero) return $rounded.ToString("0.0", [System.Globalization.CultureInfo]::InvariantCulture) } # Computes the planned_end_date a ticket should have, or $null when it should be # left alone. The base is the latest conversation timestamp (any kind) when # present, else created_at. The target is base + N business days at the target # hour in the target zone/offset, clamped so it is never in the past. Returns # $null when there is no base date, or when the ticket already holds that exact # instant (so re-running is a no-op). function Get-FSvcPlannedEndDate { param( [Parameter(Mandatory)]$Ticket, [AllowNull()]$LatestConversationAt, [datetimeoffset]$Now, [int]$BusinessDays = 3, [int]$TargetHour = 17, [AllowNull()]$Offset ) $base = ConvertTo-FSDateTimeOffset $LatestConversationAt if ($null -eq $base) { $base = ConvertTo-FSDateTimeOffset $Ticket.created_at } if ($null -eq $base) { return $null } $target = Get-FSvcTargetEndDate -Base $base -Days $BusinessDays -Hour $TargetHour -Offset $Offset -Now $Now if (-not (Test-FSvcEndDateNeedsUpdate -PlannedEndDate $Ticket.planned_end_date -Target $target)) { return $null } return $target } # Orders overview rows: groups in report order (unassigned, waiting, # awaiting_agent), and within a group by Days descending (longest-waiting # first). Returns a new array; the input is not mutated. function Sort-FSvcOverviewRows { param([AllowEmptyCollection()][object[]]$Rows) if (-not $Rows) { return @() } $rank = @{ 'unassigned' = 0; 'waiting' = 1; 'awaiting_agent' = 2 } return @($Rows | Sort-Object -Property @{ Expression = { if ($rank.ContainsKey($_.Category)) { $rank[$_.Category] } else { 99 } } }, @{ Expression = { [double]$_.Days }; Descending = $true }) } # Humanizes a business-day count. At a day or more the display is "Nd Nh"; # below a day it keeps minute resolution ("1h 30m", "20m"), because sub-day # ages are the ones where hours alone are misleading. Callers should pass the # unrounded business-day value (the numeric Days property is rounded to 1 dp). function Format-FSvcDuration { param([double]$Days) if ($Days -lt 0) { $Days = 0 } $minutes = [int][math]::Round($Days * 1440, 0, [System.MidpointRounding]::AwayFromZero) $days = [int][math]::Floor($minutes / 1440) $rem = $minutes - ($days * 1440) $hours = [int][math]::Floor($rem / 60) $mins = $rem - ($hours * 60) if ($days -gt 0) { if ($hours -gt 0) { return ('{0}d {1}h' -f $days, $hours) } return ('{0}d' -f $days) } if ($hours -gt 0) { if ($mins -gt 0) { return ('{0}h {1}m' -f $hours, $mins) } return ('{0}h' -f $hours) } return ('{0}m' -f $mins) } # Builds one overview row. RawDays is the unrounded business-day count: the # numeric Days property is rounded to one decimal (stable for sorting), while # Elapsed keeps sub-day resolution. function New-FSvcOverviewRow { param( [string]$Category, [object]$Ticket, [double]$RawDays, [AllowNull()]$Since, [AllowNull()]$Unanswered, [string]$BaseUrl ) return [pscustomobject]@{ PSTypeName = 'FSvc.TicketOverviewRow' Category = $Category Id = $Ticket.id Subject = $Ticket.subject Days = [math]::Round($RawDays, 1) Elapsed = Format-FSvcDuration -Days $RawDays Since = $Since Unanswered = $Unanswered Link = ("{0}/a/tickets/{1}" -f $BaseUrl, $Ticket.id) } } # Triage decision for one ticket: which bucket it belongs to and the timestamp # its clock runs from (the anchor Since/Elapsed are measured from). Returns # $null when the ticket needs no attention. Owning bucket and anchor together # is the point: they must never disagree. function Get-FSvcTriage { param( [Parameter(Mandatory)]$Ticket, [AllowNull()]$LatestConversation, [double]$OlderThanDays, [datetimeoffset]$Now ) $responderId = $Ticket.responder_id if ($null -eq $responderId -or [int64]$responderId -lt 0) { $created = ConvertTo-FSDateTimeOffset $Ticket.created_at $days = 0.0 if ($null -ne $created) { $days = Get-FSvcBusinessDaysBetween -From $created -To $Now } return [pscustomobject]@{ Category = 'unassigned' Since = $created Days = $days } } $lastAt = $null $lastUser = [int64]0 if ($null -ne $LatestConversation) { $lastAt = $LatestConversation.At $lastUser = [int64]$LatestConversation.UserId } if ($null -ne $lastAt -and $lastUser -ne [int64]$responderId) { return [pscustomobject]@{ Category = 'awaiting_agent' Since = $lastAt Days = (Get-FSvcBusinessDaysBetween -From $lastAt -To $Now) } } $ref = $lastAt if ($null -eq $ref) { $ref = ConvertTo-FSDateTimeOffset $Ticket.created_at } if ($null -ne $ref) { $days = Get-FSvcBusinessDaysBetween -From $ref -To $Now if ($days -gt $OlderThanDays) { return [pscustomobject]@{ Category = 'waiting' Since = $ref Days = $days } } } return $null } |