Files
claudetools/clients/birth-biologic/scripts/migrate-datto-to-sharepoint.ps1
Mike Swanson a9bcbc2580 Session log: BirthBiologic Datto-to-SharePoint migration
Supply Management migrated (160 files), SPMT launched for 4 remaining
folders, Syncro ticket #109277420 opened, SPB license assigned to
sysadmin. Script, errors, SP site map, and next steps documented.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 17:59:37 -07:00

392 lines
14 KiB
PowerShell

<#
.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,
[switch]$DeltaOnly
)
$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
# Delete any existing/partial item first so upload session creation doesn't 409
$deleteUri = "${GRAPH_ROOT}/sites/${SiteId}/drive/root:/${encoded}"
try { Invoke-Graph -Method DELETE -Uri $deleteUri | Out-Null } catch {}
$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
}
} catch {
# Cancel the upload session so outer retries can start a fresh one
try { Invoke-RestMethod -Method DELETE -Uri $uploadUrl -ErrorAction SilentlyContinue } catch {}
throw
} 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 ($DeltaOnly) {
# Check if SharePoint already has this file and it's current
$encoded = Encode-Path $remotePath
$checkUri = "${GRAPH_ROOT}/sites/${SiteId}/drive/root:/${encoded}?select=lastModifiedDateTime,size"
try {
$spItem = Invoke-Graph -Method GET -Uri $checkUri
$spDate = [datetime]$spItem.lastModifiedDateTime
$localDate = $file.LastWriteTimeUtc
if ($spItem.size -gt 0 -and $spDate -ge $localDate) {
$skip++
continue
}
Write-Log " [DELTA] $relPath (SP: $($spDate.ToString('yyyy-MM-dd')) / Local: $($localDate.ToString('yyyy-MM-dd')))"
} catch {
# Not found in SP — upload it
}
}
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 | DeltaOnly=$DeltaOnly | 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 ==="