<# Published on blog.infostruction.com for public use, no warranty implied. Autotask Exporter (PS5-compatible, paging-body fix) - Discovers ZoneBase and ensures /V1.0 - Auth: Authorization: Basic … + UserName + Secret + ApiIntegrationCode - Paginates with POST + original body for /query/next - Per-email or per-domain CSV output - Dry-run toggle - Pulls TicketNotes (and optional TimeEntries) #> # ======= CONFIG ======= $user = 'api@yourdomain.com' # Basic username + UserName header $pass = 'api-user-password' # Basic password + Secret header $code = 'TRACKING CODE' # ApiIntegrationCode $companyId = 175 # Output & behavior $outRoot = '.\Autotask_Exports' $groupByDomain = $false # $true: one file per domain; $false: per contact email $includeTime = $false # $true to include TimeEntries summary column $dryRun = $false # $true = limit to first $sampleTickets and write into \DryRun $sampleTickets = 100 # ======= HTTP setup (TLS + headers) ======= [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 [System.Net.ServicePointManager]::Expect100Continue = $false $user = $user.Trim() $pass = $pass.Trim() $code = $code.Trim().Trim('"') $basic = [Convert]::ToBase64String( [Text.Encoding]::ASCII.GetBytes(("{0}:{1}" -f $user,$pass)) ) # Zone discovery (unauthenticated) $zi = Invoke-RestMethod "https://webservices.autotask.net/ATServicesRest/V1.0/ZoneInformation?user=$([uri]::EscapeDataString($user))" $ZoneBase = $zi.url.TrimEnd("/") if ($ZoneBase -notmatch "/V1\.0$") { $ZoneBase = "$ZoneBase/V1.0" } $ZoneBase = $ZoneBase.TrimEnd("/") $Headers = @{ 'Authorization' = "Basic $basic" 'ApiIntegrationCode' = $code 'Accept' = 'application/json' 'Content-Type' = 'application/json' 'UserName' = $user 'Secret' = $pass } # ======= helpers ======= function Invoke-AT { param( [string]$Method, [string]$Resource, [object]$Body ) $url = "$ZoneBase/$Resource" if ($Body -and ($Method -match 'POST|PATCH|PUT')) { Invoke-RestMethod -Method $Method -Uri $url -Headers $Headers -Body ($Body | ConvertTo-Json -Depth 8) } else { Invoke-RestMethod -Method $Method -Uri $url -Headers $Headers } } function Invoke-AT-Next { param( [string]$NextUrl, [object]$OriginalBody ) # Autotask /query/next expects POST with the *original query body* (must include filter) Invoke-RestMethod -Method POST -Uri $NextUrl -Headers $Headers -Body ($OriginalBody | ConvertTo-Json -Depth 8) } function Ensure-Folder([string]$p){ if (-not (Test-Path $p)) { [void](New-Item -ItemType Directory -Path $p) } } function Sanitize-FileName([string]$n){ if (-not $n) { return '_unknown' } $n = $n -replace '@','_' $illegal = [System.IO.Path]::GetInvalidFileNameChars() -join '' return ($n -replace "[$illegal]","-") } function Clean-LongText([string]$text){ if (-not $text) { return $null } $t = $text # Convert HTML breaks to separators $t = $t -replace '',' | ' # Strip remaining HTML tags $t = $t -replace '<.*?>',' ' # Flatten newlines $t = $t -replace '\r?\n',' | ' # Collapse whitespace $t = $t -replace '\s+',' ' return $t.Trim() } function Resolve-ByIds { param( [string]$Entity, [int[]]$Ids, [string[]]$Fields ) $map=@{} if (-not $Ids -or $Ids.Count -eq 0) { return $map } $step=200 for ($i=0; $i -lt $Ids.Count; $i+=$step) { $end = [Math]::Min($i+$step-1,$Ids.Count-1) $chunk = $Ids[$i..$end] $q=@{ filter = @(@{op='in'; field='id'; value=$chunk}) includeFields = $Fields maxRecords = $step } $res = Invoke-AT POST "$Entity/query" $q foreach ($it in $res.items) { $map[$it.id] = $it } } $map } function Get-TicketNotes([int]$TicketId){ $qNotes=@{ filter=@( @{op='eq'; field='ticketID'; value=$TicketId}, @{op='notequals';field='noteType'; value=13} ) includeFields=@( 'id','ticketID','createDateTime','title','description', 'creatorResourceID','createdByContactID','publish','noteType' ) maxRecords=500 } $all=@() $r = Invoke-AT POST 'TicketNotes/query' $qNotes if ($r.items) { $all += $r.items } $n = $r.pageDetails.nextPageUrl while ($n) { $r2 = Invoke-AT-Next $n $qNotes if ($r2.items) { $all += $r2.items } $n = $r2.pageDetails.nextPageUrl } $all } function Get-TimeEntries([int]$TicketId){ $qTime=@{ filter = @(@{op='eq'; field='ticketID'; value=$TicketId}) includeFields = @('id','ticketID','resourceID','startDateTime','endDateTime','hoursWorked','summaryNotes') maxRecords = 500 } $all=@() $r = Invoke-AT POST 'TimeEntries/query' $qTime if ($r.items) { $all += $r.items } $n = $r.pageDetails.nextPageUrl while ($n) { $r2 = Invoke-AT-Next $n $qTime if ($r2.items) { $all += $r2.items } $n = $r2.pageDetails.nextPageUrl } $all } function BucketKey($contact,$useDomain){ $email = $null if ($contact -and $contact.emailAddress) { $email = $contact.emailAddress.Trim() } if (-not $email) { return '_unknown' } if ($useDomain) { return ($email -split '@',2)[1].ToLower() } else { return $email.ToLower() } } # ======= 1) Tickets (paged) ======= Write-Host "Querying tickets for CompanyID=$companyId ..." $tickets=@() $tb=@{ filter=@(@{op='eq'; field='CompanyID'; value=$companyId}) includeFields=@( 'id','ticketNumber','title','status','priority','companyID', 'contactID','assignedResourceID','creatorResourceID', 'createDate','lastActivityDate', 'description','resolution' # <— body + resolution ) maxRecords=500 } $resp=Invoke-AT POST 'Tickets/query' $tb if ($resp.items) { $tickets += $resp.items } $n=$resp.pageDetails.nextPageUrl while ($n) { $r2 = Invoke-AT-Next $n $tb if ($r2.items) { $tickets += $r2.items } $n = $r2.pageDetails.nextPageUrl } if ($dryRun -and $tickets.Count -gt $sampleTickets) { $tickets = $tickets | Select-Object -First $sampleTickets Write-Host "DRY RUN: limiting to $sampleTickets" } Write-Host "Tickets to process: $($tickets.Count)" # ======= 2) Resolve contacts/resources ======= $contactIds = $tickets.contactID | Where-Object { $_ } | Select-Object -Unique $resIds = @($tickets.assignedResourceID; $tickets.creatorResourceID) | Where-Object { $_ } | Select-Object -Unique $contactsMap = Resolve-ByIds 'Contacts' $contactIds @('id','firstName','lastName','emailAddress') $resourcesMap= Resolve-ByIds 'Resources' $resIds @('id','firstName','lastName','email') # ======= 3) Bucket & write ======= $out = $outRoot Ensure-Folder $out if ($dryRun) { $out = Join-Path $out 'DryRun' Ensure-Folder $out } $buckets=@{} foreach ($t in $tickets) { $c = if ($t.contactID -and $contactsMap.ContainsKey($t.contactID)) { $contactsMap[$t.contactID] } else { $null } $r = if ($t.assignedResourceID -and $resourcesMap.ContainsKey($t.assignedResourceID)) { $resourcesMap[$t.assignedResourceID] } else { $null } # --- Ticket notes --- $notes = Get-TicketNotes $t.id $notesText = if ($notes) { ($notes | Sort-Object createDateTime | ForEach-Object { # Figure out who wrote it $by = 'System (Workflow)' if ($_.creatorResourceID -and $resourcesMap.ContainsKey($_.creatorResourceID)) { $res = $resourcesMap[$_.creatorResourceID] $by = "$($res.firstName) $($res.lastName) <$($res.email)>" } elseif ($_.createdByContactID -and $contactsMap.ContainsKey($_.createdByContactID)) { $ct = $contactsMap[$_.createdByContactID] $by = "$($ct.firstName) $($ct.lastName) <$($ct.emailAddress)>" } # Clean the note description $desc = Clean-LongText $_.description "[{0}] {1}: {2}" -f $_.createDateTime, $by, $desc }) -join ' | ' } else { $null } # --- Time entries (optional) --- $timeText = $null if ($includeTime) { $times = Get-TimeEntries $t.id if ($times) { $timeText = ($times | Sort-Object startDateTime | ForEach-Object { $by = 'System' if ($_.resourceID -and $resourcesMap.ContainsKey($_.resourceID)) { $res = $resourcesMap[$_.resourceID] $by = "$($res.firstName) $($res.lastName) <$($res.email)>" } $summary = Clean-LongText $_.summaryNotes "[{0} +{1}h] {2}: {3}" -f $_.startDateTime, ([math]::Round($_.hoursWorked,2)), $by, $summary }) -join ' | ' } } # Clean Description & Resolution $descClean = Clean-LongText $t.description $resClean = Clean-LongText $t.resolution $row = [pscustomobject]@{ TicketId = $t.id TicketNumber = $t.ticketNumber Title = $t.title Status = $t.status Priority = $t.priority CreateDate = $t.createDate LastActivityDate = $t.lastActivityDate ContactName = if ($c) { "$($c.firstName) $($c.lastName)" } else { $null } ContactEmail = if ($c) { $c.emailAddress } else { $null } AssignedToName = if ($r) { "$($r.firstName) $($r.lastName)" } else { $null } AssignedToEmail = if ($r) { $r.email } else { $null } Description = $descClean Resolution = $resClean NotesCombined = $notesText TimeCombined = $timeText } $key = if ($c -and $c.emailAddress) { if ($groupByDomain) { ($c.emailAddress -split '@',2)[1].ToLower() } else { $c.emailAddress.ToLower() } } else { '_unknown' } if (-not $buckets.ContainsKey($key)) { $buckets[$key] = New-Object 'System.Collections.Generic.List[object]' } [void]$buckets[$key].Add($row) } $written=0 foreach ($k in $buckets.Keys) { $safe = Sanitize-FileName $k $suffix = if ($dryRun) { "_DRYRUN" } else { "" } $file = Join-Path $out ("{0}{1}.csv" -f $safe,$suffix) $buckets[$k] | Sort-Object TicketId | Export-Csv -NoTypeInformation -Encoding UTF8 -Path $file $written++ Write-Host ("Wrote {0} rows -> {1}" -f $buckets[$k].Count,$file) } Write-Host "Done. Buckets written: $written"