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:
@@ -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)
|
||||
|
||||
364
clients/birth-biologic/scripts/migrate-datto-to-sharepoint.ps1
Normal file
364
clients/birth-biologic/scripts/migrate-datto-to-sharepoint.ps1
Normal 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 ==="
|
||||
Reference in New Issue
Block a user