Replace hand-edited JSON with a setup wizard

Melissa had to author entity.json by hand and know FTPS from SFTP to do
it - which is what broke the last two CAMBS runs. setup-entity.bat now
asks the questions and writes the file:

- protocol is a numbered menu, stored as an explicit "protocol" key that
  wins over the sftp:// host prefix and the older tls flag
- pasted schemes and paths are stripped off the host before saving
- it creates the practice folders, then logs in and checks every
  practice's remote folder exists, so a wrong path or protocol surfaces
  in seconds instead of in the next morning's run

Also drops insurance_file_type from the required path: with no setting
the engine sends every file in the date folder minus Windows clutter, so
"No .pdf files" can't happen on a folder full of TIFs. Connection
failures now print what to change rather than just WinSCP's wording.
This commit is contained in:
2026-08-26 20:03:00 -05:00
parent c5af810806
commit 0b6f8cb16d
6 changed files with 890 additions and 224 deletions
+85 -30
View File
@@ -83,8 +83,8 @@ $entityConfig = Get-Content $entityConfigFile -Raw | ConvertFrom-Json
$configTemplate = [ordered]@{
workflow = $null
insurance_file_type = $null
ftps = [ordered]@{ host = $null; port = $null; tls = $null; username = $null; password = $null; tiff_path = $null; pdf_path = $null }
sftp = [ordered]@{ host = $null; port = $null; username = $null; password = $null; patient_path = $null }
ftps = [ordered]@{ host = $null; protocol = $null; port = $null; tls = $null; username = $null; password = $null; tiff_path = $null; pdf_path = $null }
sftp = [ordered]@{ host = $null; protocol = $null; port = $null; username = $null; password = $null; patient_path = $null }
}
$addedKeys = @()
foreach ($topKey in $configTemplate.Keys) {
@@ -110,10 +110,25 @@ if ($addedKeys.Count -gt 0) {
}
$workflow = $entityConfig.workflow
$insuranceExt = if ($entityConfig.insurance_file_type) { $entityConfig.insurance_file_type.TrimStart(".") } else { "pdf" }
$export1 = Join-Path $EntityDir "Export1"
$entityName = Split-Path -Leaf $EntityDir
# By default take whatever the imaging system left in the date folder, minus
# the Windows clutter below - no need to declare pdf vs tif anywhere. Setting
# insurance_file_type pins it to one extension for entities that need that.
$insuranceExt = if ($entityConfig.insurance_file_type) { $entityConfig.insurance_file_type.TrimStart(".") } else { $null }
$junkFiles = @("thumbs.db", "desktop.ini", ".ds_store")
$junkExts = @(".tmp", ".lnk", ".ini", ".db")
function Get-UploadableFiles($folder) {
if ($insuranceExt) {
return @(Get-ChildItem -Path $folder -Filter "*.$insuranceExt" -File)
}
return @(Get-ChildItem -Path $folder -File | Where-Object {
$junkFiles -notcontains $_.Name.ToLower() -and $junkExts -notcontains $_.Extension.ToLower()
})
}
if (-not (Test-Path $export1)) {
Write-Host "ERROR: Export1 folder not found in $EntityDir" -ForegroundColor Red
Write-Log "error" "Export1 not found in $EntityDir"
@@ -121,9 +136,10 @@ if (-not (Test-Path $export1)) {
}
Write-Host ""
Write-Host "Entity: $entityName | Workflow: $workflow | File type: .$insuranceExt | Date: $today | Log: $logLevel" -ForegroundColor Cyan
$fileTypeLabel = if ($insuranceExt) { ".$insuranceExt only" } else { "any file" }
Write-Host "Entity: $entityName | Workflow: $workflow | Sending: $fileTypeLabel | Date: $today | Log: $logLevel" -ForegroundColor Cyan
Write-Host ("=" * 60)
Write-Info "=== START entity=$entityName workflow=$workflow filetype=$insuranceExt ==="
Write-Info "=== START entity=$entityName workflow=$workflow filetype=$fileTypeLabel ==="
# -- Helpers ------------------------------------------------------------------
function Merge-Config($base, $override) {
@@ -169,7 +185,7 @@ function Get-ArchiveDir($parent, $name) {
}
# Host values may be pasted as URLs, e.g. "SFTP://server.com/" - pull out the
# scheme (decides the protocol) and the bare hostname.
# scheme and the bare hostname.
function Get-HostInfo($rawHost) {
$h = ([string]$rawHost).Trim()
$scheme = $null
@@ -181,24 +197,64 @@ function Get-HostInfo($rawHost) {
return @{ Scheme = $scheme; HostName = $h }
}
# -- Upload via FTPS -----------------------------------------------------------
function Invoke-FTPSUpload($ftpConfig, $files, $remotePath, $entity, $practice) {
$hostInfo = Get-HostInfo $ftpConfig.host
$opts = New-Object WinSCP.SessionOptions
if ($hostInfo.Scheme -eq "sftp") {
# "sftp", "ftps" or "ftp". An explicit protocol setting wins; otherwise fall
# back to a scheme pasted into the host, then to the older tls true/false.
function Get-Protocol($conn, $fallback) {
if ($conn.protocol) {
$p = ([string]$conn.protocol).ToLower().Trim()
if ($p -in @("sftp", "ftps", "ftp")) { return $p }
}
$scheme = (Get-HostInfo $conn.host).Scheme
if ($scheme -in @("sftp", "ftps", "ftp")) { return $scheme }
if ($conn.tls -eq $true) { return "ftps" }
return $fallback
}
# Turns whatever WinSCP objected to into something worth acting on.
function Get-ConnectionHint($message) {
if ($message -imatch "cannot connect to an SFTP server using an FTP") {
return "This server wants SFTP. Run setup-entity.bat, change this entity's server, and pick SFTP."
}
if ($message -imatch "cannot connect to an FTP server using an SSH|expected SSH") {
return "This server wants FTP or FTPS. Run setup-entity.bat, change this entity's server, and pick FTPS."
}
if ($message -imatch "authentication|password|login incorrect|530") {
return "The username or password was rejected. Run setup-entity.bat and retype the login."
}
if ($message -imatch "host does not exist|no such host|could not resolve") {
return "That server address could not be found - check it for a typo in setup-entity.bat."
}
if ($message -imatch "timed out|timeout|refused") {
return "No answer from the server. Check the address, or ask IT whether the firewall allows it."
}
return $null
}
function Set-SessionOptions($opts, $conn, $protocol) {
$opts.HostName = (Get-HostInfo $conn.host).HostName
$opts.UserName = $conn.username
$opts.Password = $conn.password
if ($protocol -eq "sftp") {
$opts.Protocol = [WinSCP.Protocol]::Sftp
$opts.PortNumber = if ($ftpConfig.port) { [int]$ftpConfig.port } else { 22 }
$opts.PortNumber = if ($conn.port) { [int]$conn.port } else { 22 }
$opts.GiveUpSecurityAndAcceptAnySshHostKey = $true
} else {
$useTls = ($ftpConfig.tls -eq $true) -or ($hostInfo.Scheme -eq "ftps")
$opts.Protocol = [WinSCP.Protocol]::Ftp
$opts.FtpSecure = if ($useTls) { [WinSCP.FtpSecure]::Explicit } else { [WinSCP.FtpSecure]::None }
$opts.PortNumber = if ($ftpConfig.port) { [int]$ftpConfig.port } else { 21 }
if ($useTls) { $opts.GiveUpSecurityAndAcceptAnyTlsHostCertificate = $true }
$opts.PortNumber = if ($conn.port) { [int]$conn.port } else { 21 }
if ($protocol -eq "ftps") {
$opts.FtpSecure = [WinSCP.FtpSecure]::Explicit
$opts.GiveUpSecurityAndAcceptAnyTlsHostCertificate = $true
} else {
$opts.FtpSecure = [WinSCP.FtpSecure]::None
}
}
$opts.HostName = $hostInfo.HostName
$opts.UserName = $ftpConfig.username
$opts.Password = $ftpConfig.password
}
# -- Upload the daily date-folder files ----------------------------------------
function Invoke-FTPSUpload($ftpConfig, $files, $remotePath, $entity, $practice) {
$protocol = Get-Protocol $ftpConfig "ftp"
$opts = New-Object WinSCP.SessionOptions
Set-SessionOptions $opts $ftpConfig $protocol
$session = New-Object WinSCP.Session
if ($logLevel -eq "debug") {
@@ -207,7 +263,7 @@ function Invoke-FTPSUpload($ftpConfig, $files, $remotePath, $entity, $practice)
$ok = 0; $fail = 0
try {
Write-Debug "Connect: $($opts.Protocol) $($hostInfo.HostName):$($opts.PortNumber) user=$($ftpConfig.username)"
Write-Debug "Connect: $protocol $($opts.HostName):$($opts.PortNumber) user=$($ftpConfig.username)"
$session.Open($opts)
foreach ($file in $files) {
$remote = $remotePath.TrimEnd("/") + "/" + $file.Name
@@ -228,6 +284,8 @@ function Invoke-FTPSUpload($ftpConfig, $files, $remotePath, $entity, $practice)
$err = $_.Exception.Message
Write-Log "error" "FTPS connect FAILED entity=$entity practice=$practice host=$($ftpConfig.host) error=$err"
Write-Host " Connection failed: $err" -ForegroundColor Red
$hint = Get-ConnectionHint $err
if ($hint) { Write-Host " -> $hint" -ForegroundColor Yellow }
$fail += $files.Count
} finally {
$session.Dispose()
@@ -237,14 +295,9 @@ function Invoke-FTPSUpload($ftpConfig, $files, $remotePath, $entity, $practice)
# -- Upload via SFTP -----------------------------------------------------------
function Invoke-SFTPUpload($sftpConfig, $files, $remotePath, $entity, $practice) {
$hostInfo = Get-HostInfo $sftpConfig.host
$protocol = Get-Protocol $sftpConfig "sftp"
$opts = New-Object WinSCP.SessionOptions
$opts.Protocol = [WinSCP.Protocol]::Sftp
$opts.HostName = $hostInfo.HostName
$opts.PortNumber = if ($sftpConfig.port) { [int]$sftpConfig.port } else { 22 }
$opts.UserName = $sftpConfig.username
$opts.Password = $sftpConfig.password
$opts.GiveUpSecurityAndAcceptAnySshHostKey = $true
Set-SessionOptions $opts $sftpConfig $protocol
$session = New-Object WinSCP.Session
if ($logLevel -eq "debug") {
@@ -274,6 +327,8 @@ function Invoke-SFTPUpload($sftpConfig, $files, $remotePath, $entity, $practice)
$err = $_.Exception.Message
Write-Log "error" "SFTP connect FAILED entity=$entity practice=$practice host=$($sftpConfig.host) error=$err"
Write-Host " Connection failed: $err" -ForegroundColor Red
$hint = Get-ConnectionHint $err
if ($hint) { Write-Host " -> $hint" -ForegroundColor Yellow }
$fail += $files.Count
} finally {
$session.Dispose()
@@ -314,7 +369,7 @@ foreach ($practice in $practices) {
Write-Host " No $today\ folder - skipping insurance." -ForegroundColor Gray
Write-Info "SKIP entity=$entityName practice=$pracName reason=no date folder"
} else {
$allInsuranceFiles = Get-ChildItem -Path $dateDir -Filter "*.$insuranceExt" -File
$allInsuranceFiles = Get-UploadableFiles $dateDir
$ptStmts = $allInsuranceFiles | Where-Object { $_.Name -imatch 'pt[\s_]*stmt' }
$insFiles = $allInsuranceFiles | Where-Object { $_.Name -notmatch 'pt[\s_]*stmt' }
@@ -364,8 +419,8 @@ foreach ($practice in $practices) {
Write-Host " $today\ NOT archived - $ignored file(s) were ignored (unconfigured path)." -ForegroundColor Yellow
Write-Info "NOARCHIVE entity=$entityName practice=$pracName reason=$ignored ignored files"
} elseif ($insFiles.Count + $ptStmts.Count -eq 0) {
Write-Host " No .$insuranceExt files in $today\." -ForegroundColor Gray
Write-Info "SKIP entity=$entityName practice=$pracName reason=no .$insuranceExt files in date folder"
Write-Host " Nothing to send in $today\ ($fileTypeLabel)." -ForegroundColor Gray
Write-Info "SKIP entity=$entityName practice=$pracName reason=no matching files in date folder"
}
$totalOk += $insResult.Ok; $totalFail += $insResult.Fail