sync: auto-sync from DESKTOP-0O8A1RL at 2026-04-21 16:24:03

Author: Mike Swanson
Machine: DESKTOP-0O8A1RL
Timestamp: 2026-04-21 16:24:03
This commit is contained in:
2026-04-21 16:24:07 -07:00
parent f15862440e
commit 1865ae705b
2 changed files with 374 additions and 3 deletions

View File

@@ -58,12 +58,16 @@ API_KEY=$(sops -d D:/vault/msp-tools/syncro.sops.yaml | py -c "import sys,yaml;
- `subject` (required for create)
- `problem_type` (string, free-form)
- `status` (string, one of the statuses above)
- `priority` (string)
- `priority` (string) — set this; leave blank only if user says not to
- `due_date` (ISO date)
- `user_id` (assign to tech)
- `user_id` (assign to tech) — set this; Mike = 1735, Winter = 1737, Rob = 1760
- `contact_id` (customer contact)
- `ticket_type_id` (ticket category)
**Always set `user_id` and `priority` on create** unless the user says otherwise. Ask if unknown.
- Assignee = whoever worked the ticket (Mike = 1735, Winter = 1737, Rob = 1760)
- Priority = `Normal` by default; `Urgent` for emergency/after-hours tickets
#### Comments
| Operation | Method | Endpoint | Body |
@@ -109,7 +113,7 @@ Two verified ways to add billable time. Both produce ticket line items that tran
# Add
curl -s -X POST "${BASE}/tickets/${ID}/add_line_item?api_key=${API_KEY}" \
-H "Content-Type: application/json" \
-d '{"product_id": 1190473, "quantity": 0.5, "price_retail": 150.00, "name": "Labor - Remote Business", "description": "Work description"}'
-d '{"product_id": 1190473, "quantity": 0.5, "price_retail": 150.00, "name": "Labor - Remote Business", "description": "Work description", "taxable": false}'
# Remove
curl -s -X POST "${BASE}/tickets/${ID}/remove_line_item?api_key=${API_KEY}" \
@@ -144,6 +148,9 @@ curl -s -X POST "${BASE}/tickets/${ID}/delete_timer_entry?api_key=${API_KEY}" \
- `product_id` — labor product ID (see list below)
- `quantity` — decimal hours (0.5 = 30 min, 1.0 = 1 hour)
- `price_retail`**only price field that saves**; `price`, `retail_price`, `rate`, `price_cents` all silently ignored and leave line at $0.00
- `taxable: false`**always set explicitly**; labor products default to no-tax in GUI but the API applies tax if this is omitted
**Do NOT remove ticket line items after invoicing.** Leave them on the ticket — the "Add/View Charges" button and billing verification by techs depends on seeing line items there.
**Labor product IDs:**
- `1190473` — Labor - Remote Business (standard remote work)

View File

@@ -0,0 +1,364 @@
<#
.SYNOPSIS
Migrates BirthBiologic Datto Workplace files to SharePoint Online.
.DESCRIPTION
Reads files from C:\Users\Public\Desktop\Datto Workplace Server Projects on BB-SERVER
and uploads them to the corresponding SharePoint document libraries via Microsoft Graph API.
Supports resume: already-uploaded files are skipped based on a state file.
.PARAMETER ClientSecret
App secret for the ComputerGuru Tenant Admin app (tenant-admin tier).
Can also be set via env var BB_MIGRATION_SECRET.
.PARAMETER Token
Pre-obtained Graph Bearer token. Use if you already have a valid token.
.PARAMETER DattoRoot
Path to the Datto Workplace root folder.
Default: C:\Users\Public\Desktop\Datto Workplace Server Projects
.PARAMETER OnlyFolder
Migrate only this top-level Datto folder (e.g. "Donor Services").
Omit to migrate all folders.
.PARAMETER WhatIf
Dry run. Lists what would be uploaded without transferring anything.
.PARAMETER Resume
Skip files already recorded in the state file. Default: true.
Pass -Resume:$false to force re-upload everything.
.EXAMPLE
# Dry run on one folder
.\migrate-datto-to-sharepoint.ps1 -ClientSecret "xxx" -OnlyFolder "Supply Management" -WhatIf
.EXAMPLE
# Real migration, one folder at a time
.\migrate-datto-to-sharepoint.ps1 -ClientSecret "xxx" -OnlyFolder "Supply Management"
.NOTES
Requires ComputerGuru Tenant Admin app consented in BirthBiologic tenant
with Sites.ReadWrite.All application permission.
Run on BB-SERVER where Datto files are locally accessible.
#>
[CmdletBinding()]
param(
[string]$ClientSecret = $env:BB_MIGRATION_SECRET,
[string]$Token = "",
[string]$DattoRoot = "C:\Users\Public\Desktop\Datto Workplace Server Projects",
[string]$OnlyFolder = "",
[switch]$WhatIf,
[bool]$Resume = $true
)
$ErrorActionPreference = "Stop"
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
# Constants
$TENANT_ID = "19a568e8-9e88-413b-9341-cbc224b39145"
$CLIENT_ID = "709e6eed-0711-4875-9c44-2d3518c47063"
$GRAPH_ROOT = "https://graph.microsoft.com/v1.0"
$CHUNK_SIZE = 60 * 1024 * 1024
$SMALL_FILE = 4 * 1024 * 1024
$MAX_RETRY = 3
$LOG_DIR = "C:\GuruMigration"
$LOG_FILE = "$LOG_DIR\bb-migration-$(Get-Date -Format 'yyyy-MM-dd').log"
$STATE_FILE = "$LOG_DIR\bb-migration-state.json"
# Site ID map: Datto folder name -> SharePoint site ID
$SITE_MAP = [ordered]@{
"Admin" = "birthbiologic.sharepoint.com,1baf65c1-c4b3-4602-9111-1f99ae800023,4a1a8706-42a5-4d72-bb7d-75a846036623"
"Birth Biologic Activity Reports" = "birthbiologic.sharepoint.com,1baf65c1-c4b3-4602-9111-1f99ae800023,4a1a8706-42a5-4d72-bb7d-75a846036623"
"Donor Services" = "birthbiologic.sharepoint.com,bcbfa272-dc85-424c-af66-3f14c75ffeb4,8b0975dd-e0ac-4ad0-9921-d7c9c2858865"
"ITSvcs" = "birthbiologic.sharepoint.com,83b636d9-2e38-4fd8-83eb-431ab1f0067b,8b0975dd-e0ac-4ad0-9921-d7c9c2858865"
"Quality Department" = "birthbiologic.sharepoint.com,5fd38089-b87f-4472-8d17-2d8bbc2759a2,8b0975dd-e0ac-4ad0-9921-d7c9c2858865"
"Supply Management" = "birthbiologic.sharepoint.com,4700ecf3-25ba-41b6-918c-9fe620038172,8b0975dd-e0ac-4ad0-9921-d7c9c2858865"
}
# Folders routed into a sub-path at the destination
$SUBFOLDER_PREFIX = @{
"Birth Biologic Activity Reports" = "Birth Biologic Activity Reports"
}
# Logging
if (-not (Test-Path $LOG_DIR)) { New-Item -ItemType Directory -Path $LOG_DIR | Out-Null }
function Write-Log {
param([string]$Msg, [string]$Level = "INFO")
$line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [$Level] $Msg"
Write-Host $line
Add-Content -Path $LOG_FILE -Value $line -Encoding UTF8
}
# State tracking for resume
$script:state = @{}
if ($Resume -and (Test-Path $STATE_FILE)) {
$raw = Get-Content $STATE_FILE -Raw | ConvertFrom-Json
$raw.PSObject.Properties | ForEach-Object { $script:state[$_.Name] = $_.Value }
Write-Log "Resume state loaded: $($script:state.Count) files already uploaded"
}
function Save-State {
$script:state | ConvertTo-Json -Depth 2 | Set-Content $STATE_FILE -Encoding UTF8
}
function Mark-Done {
param([string]$Key)
$script:state[$Key] = (Get-Date -Format "o")
if ($script:state.Count % 25 -eq 0) { Save-State }
}
function Is-Done {
param([string]$Key)
return $script:state.ContainsKey($Key)
}
# Token management
$script:tokenValue = ""
$script:tokenFetched = 0
$script:tokenExpiry = 0
function Get-Token {
$now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
if ($script:tokenValue -and ($now + 300) -lt ($script:tokenFetched + $script:tokenExpiry)) {
return $script:tokenValue
}
if ($Token) {
$script:tokenValue = $Token
$script:tokenFetched = $now
$script:tokenExpiry = 3600
return $Token
}
if (-not $ClientSecret) {
throw "Provide -ClientSecret or -Token, or set env var BB_MIGRATION_SECRET."
}
$body = "grant_type=client_credentials&client_id=$CLIENT_ID&client_secret=$([uri]::EscapeDataString($ClientSecret))&scope=https://graph.microsoft.com/.default"
$resp = Invoke-RestMethod -Method POST `
-Uri "https://login.microsoftonline.com/$TENANT_ID/oauth2/v2.0/token" `
-Body $body -ContentType "application/x-www-form-urlencoded"
$script:tokenValue = $resp.access_token
$script:tokenFetched = $now
$script:tokenExpiry = $resp.expires_in
Write-Log "Token acquired (expires in $($resp.expires_in)s)"
return $script:tokenValue
}
# Graph API call with retry
function Invoke-Graph {
param(
[string]$Method,
[string]$Uri,
[hashtable]$ExtraHeaders = @{},
[object]$Body = $null,
[string]$ContentType = "application/json",
[int]$Attempt = 0
)
$tok = Get-Token
$headers = @{ Authorization = "Bearer $tok" }
foreach ($k in $ExtraHeaders.Keys) { $headers[$k] = $ExtraHeaders[$k] }
$params = @{
Method = $Method
Uri = $Uri
Headers = $headers
ContentType = $ContentType
}
if ($null -ne $Body) { $params.Body = $Body }
try {
return Invoke-RestMethod @params
} catch {
$status = 0
if ($_.Exception.Response) { $status = [int]$_.Exception.Response.StatusCode }
if (($status -eq 429 -or $status -ge 500) -and $Attempt -lt $MAX_RETRY) {
$wait = [math]::Pow(2, $Attempt + 1)
Write-Log "HTTP $status - retry $($Attempt+1)/$MAX_RETRY in ${wait}s" "WARN"
Start-Sleep -Seconds $wait
return Invoke-Graph -Method $Method -Uri $Uri -ExtraHeaders $ExtraHeaders `
-Body $Body -ContentType $ContentType -Attempt ($Attempt + 1)
}
throw
}
}
# Encode each path segment individually
function Encode-Path {
param([string]$RelPath)
return ($RelPath.Split('/') | ForEach-Object { [uri]::EscapeDataString($_) }) -join '/'
}
# Upload small file (<= 4 MB)
function Upload-SmallFile {
param([string]$SiteId, [string]$RemotePath, [string]$LocalPath)
$encoded = Encode-Path $RemotePath
$uri = "${GRAPH_ROOT}/sites/${SiteId}/drive/root:/${encoded}:/content"
$bytes = [System.IO.File]::ReadAllBytes($LocalPath)
return Invoke-Graph -Method PUT -Uri $uri -Body $bytes -ContentType "application/octet-stream"
}
# Upload large file via chunked upload session
function Upload-LargeFile {
param([string]$SiteId, [string]$RemotePath, [string]$LocalPath)
$fileSize = (Get-Item $LocalPath).Length
$encoded = Encode-Path $RemotePath
$sessionUri = "${GRAPH_ROOT}/sites/${SiteId}/drive/root:/${encoded}:/createUploadSession"
$sessionBody = '{"item":{"@microsoft.graph.conflictBehavior":"replace"}}'
$session = Invoke-Graph -Method POST -Uri $sessionUri -Body $sessionBody
$uploadUrl = $session.uploadUrl
$stream = [System.IO.File]::OpenRead($LocalPath)
$buf = New-Object byte[] $CHUNK_SIZE
$offset = 0L
$resp = $null
try {
while ($offset -lt $fileSize) {
$read = $stream.Read($buf, 0, $CHUNK_SIZE)
if ($read -eq 0) { break }
$chunk = if ($read -eq $CHUNK_SIZE) { $buf } else { $buf[0..($read - 1)] }
$end = $offset + $read - 1
$pct = [int]($offset * 100 / $fileSize)
Write-Host "`r $pct% ($([math]::Round($offset/1MB,0)) MB / $([math]::Round($fileSize/1MB,0)) MB) " -NoNewline
$attempt = 0
$ok = $false
while (-not $ok) {
try {
$resp = Invoke-RestMethod -Method PUT -Uri $uploadUrl `
-Headers @{ "Content-Range" = "bytes $offset-$end/$fileSize" } `
-Body $chunk -ContentType "application/octet-stream"
$ok = $true
} catch {
$attempt++
if ($attempt -ge $MAX_RETRY) { throw }
Start-Sleep -Seconds ([math]::Pow(2, $attempt))
}
}
$offset += $read
}
} finally {
$stream.Dispose()
Write-Host ""
}
return $resp
}
function Upload-File {
param([string]$SiteId, [string]$RemotePath, [string]$LocalPath)
$size = (Get-Item $LocalPath).Length
if ($size -le $SMALL_FILE) {
return Upload-SmallFile -SiteId $SiteId -RemotePath $RemotePath -LocalPath $LocalPath
} else {
return Upload-LargeFile -SiteId $SiteId -RemotePath $RemotePath -LocalPath $LocalPath
}
}
# Migrate one Datto top-level folder
function Migrate-Folder {
param([string]$DattoFolder, [string]$SiteId, [string]$SitePrefix)
$srcRoot = Join-Path $DattoRoot $DattoFolder
if (-not (Test-Path $srcRoot)) {
Write-Log "Source not found, skipping: $srcRoot" "WARN"
return $null
}
$files = Get-ChildItem -Path $srcRoot -Recurse -File
$total = $files.Count
$done = 0
$skip = 0
$fail = 0
Write-Log "[$DattoFolder] $total files to process..."
foreach ($file in $files) {
$relPath = $file.FullName.Substring($srcRoot.Length).TrimStart('\').Replace('\', '/')
$remotePath = if ($SitePrefix) { "$SitePrefix/$relPath" } else { $relPath }
$stateKey = "$DattoFolder/$relPath"
if ($Resume -and (Is-Done $stateKey)) {
$skip++
continue
}
$sizeMB = [math]::Round($file.Length / 1MB, 2)
Write-Log " [$($done + $skip + $fail + 1)/$total] $relPath ($sizeMB MB)"
if ($WhatIf) {
Write-Log " [WHATIF] -> $remotePath"
$done++
continue
}
$attempt = 0
$ok = $false
while ($attempt -lt $MAX_RETRY -and -not $ok) {
try {
Upload-File -SiteId $SiteId -RemotePath $remotePath -LocalPath $file.FullName | Out-Null
Mark-Done $stateKey
$done++
$ok = $true
} catch {
$attempt++
$errMsg = $_.Exception.Message
if ($attempt -ge $MAX_RETRY) {
Write-Log " [FAIL] $relPath - $errMsg" "ERROR"
$fail++
} else {
Write-Log " [RETRY $attempt] $relPath - $errMsg" "WARN"
Start-Sleep -Seconds ([math]::Pow(2, $attempt))
}
}
}
}
Save-State
Write-Log "[$DattoFolder] Done: $done uploaded, $skip skipped, $fail failed / $total total"
return [pscustomobject]@{ Folder = $DattoFolder; Uploaded = $done; Skipped = $skip; Failed = $fail; Total = $total }
}
# Main
Write-Log "=== BirthBiologic Datto -> SharePoint Migration ==="
Write-Log "Source: $DattoRoot"
Write-Log "WhatIf=$WhatIf | Resume=$Resume | OnlyFolder=$(if ($OnlyFolder) { $OnlyFolder } else { '(all)' })"
if (-not (Test-Path $DattoRoot)) {
Write-Log "ERROR: Datto root not found: $DattoRoot" "ERROR"
exit 1
}
try { $null = Get-Token; Write-Log "Auth OK" }
catch { Write-Log "Auth FAILED: $_" "ERROR"; exit 1 }
$results = @()
$startTime = Get-Date
foreach ($folderName in $SITE_MAP.Keys) {
if ($OnlyFolder -and $folderName -ne $OnlyFolder) { continue }
$siteId = $SITE_MAP[$folderName]
$prefix = if ($SUBFOLDER_PREFIX.ContainsKey($folderName)) { $SUBFOLDER_PREFIX[$folderName] } else { "" }
$result = Migrate-Folder -DattoFolder $folderName -SiteId $siteId -SitePrefix $prefix
if ($result) { $results += $result }
}
Save-State
$elapsed = (Get-Date) - $startTime
$totalUp = ($results | Measure-Object -Property Uploaded -Sum).Sum
$totalSkip = ($results | Measure-Object -Property Skipped -Sum).Sum
$totalFail = ($results | Measure-Object -Property Failed -Sum).Sum
Write-Log ""
Write-Log "=== Summary ==="
Write-Log "Elapsed: $([math]::Round($elapsed.TotalMinutes, 1)) min"
Write-Log "Uploaded: $totalUp Skipped: $totalSkip Failed: $totalFail"
foreach ($r in $results) {
Write-Log " $($r.Folder): $($r.Uploaded) up / $($r.Skipped) skip / $($r.Failed) fail / $($r.Total) total"
}
if ($totalFail -gt 0) { Write-Log "Some failures - check log: $LOG_FILE" "WARN"; exit 1 }
Write-Log "=== Done ==="