From 1865ae705bb8c6d11957a4b2da56dcab12638197 Mon Sep 17 00:00:00 2001 From: Mike Swanson Date: Tue, 21 Apr 2026 16:24:07 -0700 Subject: [PATCH] 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 --- .claude/commands/syncro.md | 13 +- .../scripts/migrate-datto-to-sharepoint.ps1 | 364 ++++++++++++++++++ 2 files changed, 374 insertions(+), 3 deletions(-) create mode 100644 clients/birth-biologic/scripts/migrate-datto-to-sharepoint.ps1 diff --git a/.claude/commands/syncro.md b/.claude/commands/syncro.md index a592b5d..ecd063c 100644 --- a/.claude/commands/syncro.md +++ b/.claude/commands/syncro.md @@ -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) diff --git a/clients/birth-biologic/scripts/migrate-datto-to-sharepoint.ps1 b/clients/birth-biologic/scripts/migrate-datto-to-sharepoint.ps1 new file mode 100644 index 0000000..c9e9cac --- /dev/null +++ b/clients/birth-biologic/scripts/migrate-datto-to-sharepoint.ps1 @@ -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 ==="