Files
melissa-uploader/Setup-Entity.ps1
T
blance 0b6f8cb16d 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.
2026-08-26 20:03:00 -05:00

649 lines
25 KiB
PowerShell

# Interactive setup - asks plain questions and writes entity.json so nobody has
# to hand-edit JSON. Called by setup-entity.bat. Safe to re-run any time.
$scriptRoot = Split-Path -Parent $PSCommandPath
$entitiesDir = Join-Path $scriptRoot "Entities"
$winscpDll = Join-Path $scriptRoot "WinSCP\WinSCPnet.dll"
if (-not (Test-Path $entitiesDir)) { New-Item -ItemType Directory -Path $entitiesDir | Out-Null }
# -- Asking questions ---------------------------------------------------------
function Read-Answer($question, $default, [switch]$AllowBlank) {
while ($true) {
if ($default) {
Write-Host "$question " -NoNewline -ForegroundColor White
Write-Host "[$default]" -NoNewline -ForegroundColor DarkGray
Write-Host ": " -NoNewline -ForegroundColor White
} else {
Write-Host "${question}: " -NoNewline -ForegroundColor White
}
$answer = Read-Host
if ([string]::IsNullOrWhiteSpace($answer)) {
if ($default) { return $default }
if ($AllowBlank) { return $null }
Write-Host " (this one is required)" -ForegroundColor Yellow
continue
}
return $answer.Trim()
}
}
function Read-YesNo($question, $default) {
$hint = if ($default -eq "y") { "Y/n" } else { "y/N" }
while ($true) {
Write-Host "$question " -NoNewline -ForegroundColor White
Write-Host "($hint)" -NoNewline -ForegroundColor DarkGray
Write-Host ": " -NoNewline -ForegroundColor White
$answer = Read-Host
if ([string]::IsNullOrWhiteSpace($answer)) { $answer = $default }
switch ($answer.Trim().ToLower()) {
"y" { return $true }
"yes" { return $true }
"n" { return $false }
"no" { return $false }
}
Write-Host " (please answer y or n)" -ForegroundColor Yellow
}
}
function Read-Choice($question, $options, $default) {
Write-Host ""
Write-Host $question -ForegroundColor White
for ($i = 0; $i -lt $options.Count; $i++) {
Write-Host (" {0}. {1}" -f ($i + 1), $options[$i])
}
while ($true) {
Write-Host "Choose 1-$($options.Count) " -NoNewline -ForegroundColor White
Write-Host "[$default]" -NoNewline -ForegroundColor DarkGray
Write-Host ": " -NoNewline -ForegroundColor White
$answer = Read-Host
if ([string]::IsNullOrWhiteSpace($answer)) { $answer = [string]$default }
$n = 0
if ([int]::TryParse(([string]$answer).Trim(), [ref]$n) -and $n -ge 1 -and $n -le $options.Count) {
return $n
}
Write-Host " (type a number between 1 and $($options.Count))" -ForegroundColor Yellow
}
}
function Write-Header($text) {
Write-Host ""
Write-Host ("=" * 64) -ForegroundColor Cyan
Write-Host " $text" -ForegroundColor Cyan
Write-Host ("=" * 64) -ForegroundColor Cyan
}
function Write-Section($text) {
Write-Host ""
Write-Host "--- $text " -NoNewline -ForegroundColor Cyan
Write-Host ("-" * [Math]::Max(1, (58 - $text.Length))) -ForegroundColor Cyan
}
# -- Config file read/write ---------------------------------------------------
function Get-EntityNames {
if (-not (Test-Path $entitiesDir)) { return @() }
return @(Get-ChildItem -Path $entitiesDir -Directory |
Where-Object { $_.Name -ne "EXAMPLE" } |
ForEach-Object { $_.Name })
}
function Save-EntityConfig($entityName, $config) {
$dir = Join-Path $entitiesDir $entityName
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir | Out-Null }
$file = Join-Path $dir "entity.json"
$config | ConvertTo-Json -Depth 10 | Set-Content -Path $file -Encoding UTF8
return $file
}
function Get-EntityConfig($entityName) {
$file = Join-Path (Join-Path $entitiesDir $entityName) "entity.json"
if (-not (Test-Path $file)) { return $null }
return Get-Content $file -Raw | ConvertFrom-Json
}
function Copy-RunBat($entityName) {
$source = Join-Path $scriptRoot "run.bat"
$target = Join-Path (Join-Path $entitiesDir $entityName) "run.bat"
if ((Test-Path $source) -and -not (Test-Path $target)) {
Copy-Item -Path $source -Destination $target
}
}
# -- Asking about one server --------------------------------------------------
# Returns a hashtable of connection settings, or $null if the user backs out.
function Read-ServerDetails($label, $existing) {
$defaultHost = $null
$defaultUser = $null
$defaultProto = 1
if ($existing -and $existing.host) {
$defaultHost = (([string]$existing.host) -replace '^\w+://', '').Split('/')[0].Trim()
$defaultUser = $existing.username
switch (Get-ConnProtocol $existing "sftp") {
"ftps" { $defaultProto = 2 }
"ftp" { $defaultProto = 3 }
}
}
Write-Host ""
Write-Host "Server address for $label" -ForegroundColor White
Write-Host " Just the name - no sftp:// or ftp:// in front." -ForegroundColor DarkGray
Write-Host " Example: sft.polluxsystems.com" -ForegroundColor DarkGray
$serverHost = Read-Answer "Server address" $defaultHost
# Strip a pasted scheme/path so it can't break the connection later.
$serverHost = ($serverHost -replace '^\w+://', '').Split('/')[0].Trim()
$protoChoice = Read-Choice "How does $serverHost receive files?" @(
"SFTP - secure, port 22 (most common)",
"FTPS - secure FTP, port 21",
"FTP - plain, no encryption, port 21"
) $defaultProto
$protocol = @("sftp", "ftps", "ftp")[$protoChoice - 1]
Write-Host ""
$username = Read-Answer "Username" $defaultUser
$password = $null
if ($existing -and $existing.password) {
Write-Host ""
Write-Host "A password is already saved for this server." -ForegroundColor DarkGray
if (Read-YesNo "Keep the saved password?" "y") {
$password = $existing.password
}
}
if (-not $password) {
$password = Read-Answer "Password" $null
}
return @{
host = $serverHost
protocol = $protocol
username = $username
password = $password
}
}
function Read-RemotePath($what, $default) {
Write-Host ""
Write-Host "Where do $what go on the server?" -ForegroundColor White
Write-Host " Type {practice} where the practice name belongs and it gets" -ForegroundColor DarkGray
Write-Host " filled in automatically for each one - so one line covers all." -ForegroundColor DarkGray
Write-Host " Example: /home/clients/CAMBS/{practice}" -ForegroundColor DarkGray
return Read-Answer "Folder path" $default
}
# -- New / edit entity --------------------------------------------------------
function New-Entity {
Write-Header "Set up a new entity"
Write-Host ""
Write-Host "The entity name is the folder name you use for this group of" -ForegroundColor DarkGray
Write-Host "practices - for example CAMBS, RMI, INLAND." -ForegroundColor DarkGray
$entityName = $null
while (-not $entityName) {
$candidate = Read-Answer "Entity name" $null
if ($candidate.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars()) -ge 0) {
Write-Host " A folder name cannot contain \ / : * ? "" < > | - try again." -ForegroundColor Yellow
continue
}
$entityName = $candidate
}
$entityDir = Join-Path $entitiesDir $entityName
if (Test-Path (Join-Path $entityDir "entity.json")) {
Write-Host ""
Write-Host "$entityName is already set up." -ForegroundColor Yellow
if (-not (Read-YesNo "Start over and replace its settings?" "n")) { return }
}
Write-Section "The daily upload"
Write-Host "These are the dated folders (like 20260826) full of scanned images." -ForegroundColor DarkGray
$daily = Read-ServerDetails "the daily files" $null
$tiffPath = Read-RemotePath "the daily image files" $null
Write-Host ""
$pdfPath = $null
Write-Host "Some entities get PT STMT files mixed into the same dated folder," -ForegroundColor DarkGray
Write-Host "and those go to a different folder on the server." -ForegroundColor DarkGray
if (Read-YesNo "Do PT STMT files show up in the dated folders for $entityName?" "n") {
$pdfPath = Read-RemotePath "the PT STMT files" $null
}
Write-Section "Patient files"
Write-Host "These come from Patient\Export\PT STMT_<date>\ - the folders the" -ForegroundColor DarkGray
Write-Host "rename script creates. Many entities do not use this at all." -ForegroundColor DarkGray
$patient = $null
$patientPath = $null
if (Read-YesNo "Does $entityName send patient files too?" "n") {
$samePlace = Read-Choice "Which server do the patient files go to?" @(
"The same server as the daily files ($($daily.host))",
"A different server"
) 1
if ($samePlace -eq 1) {
$patient = @{
host = $daily.host
protocol = $daily.protocol
username = $daily.username
password = $daily.password
}
} else {
$patient = Read-ServerDetails "the patient files" $null
}
$patientPath = Read-RemotePath "the patient .txt/.tif files" $null
}
$config = New-ConfigObject $daily $tiffPath $pdfPath $patient $patientPath
$file = Save-EntityConfig $entityName $config
Copy-RunBat $entityName
Write-Host ""
Write-Host "Saved $file" -ForegroundColor Green
Add-Practices $entityName
Write-Host ""
if (Read-YesNo "Test the connection now?" "y") { Test-Entity $entityName }
}
function New-ConfigObject($daily, $tiffPath, $pdfPath, $patient, $patientPath) {
$workflow = if ($patient) { "insurance+patient" } else { "insurance" }
$ftps = [ordered]@{
host = $daily.host
protocol = $daily.protocol
port = $null
username = $daily.username
password = $daily.password
tiff_path = $tiffPath
pdf_path = $pdfPath
}
$sftp = [ordered]@{
host = $null
protocol = $null
port = $null
username = $null
password = $null
patient_path = $null
}
if ($patient) {
$sftp.host = $patient.host
$sftp.protocol = $patient.protocol
$sftp.username = $patient.username
$sftp.password = $patient.password
$sftp.patient_path = $patientPath
}
return [PSCustomObject][ordered]@{
"_note" = "Written by setup-entity.bat - run it again to change anything. The 'ftps' section is the daily upload (it can be SFTP too - see protocol); the 'sftp' section is the patient files."
workflow = $workflow
ftps = [PSCustomObject]$ftps
sftp = [PSCustomObject]$sftp
}
}
function Add-Practices($entityName) {
$export1 = Join-Path (Join-Path $entitiesDir $entityName) "Export1"
if (-not (Test-Path $export1)) { New-Item -ItemType Directory -Path $export1 | Out-Null }
$existing = @(Get-ChildItem -Path $export1 -Directory | ForEach-Object { $_.Name })
Write-Section "Practices under $entityName"
if ($existing.Count -gt 0) {
Write-Host "Already there: $($existing -join ', ')" -ForegroundColor Gray
Write-Host ""
if (-not (Read-YesNo "Add more practice folders?" "n")) { return }
} else {
Write-Host "Type each practice name and press Enter." -ForegroundColor DarkGray
Write-Host "Press Enter on an empty line when you are done." -ForegroundColor DarkGray
}
while ($true) {
$name = Read-Answer "Practice name (blank to finish)" $null -AllowBlank
if (-not $name) { break }
if ($name.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars()) -ge 0) {
Write-Host " A folder name cannot contain \ / : * ? "" < > | - try again." -ForegroundColor Yellow
continue
}
$dir = Join-Path $export1 $name
if (Test-Path $dir) {
Write-Host " $name already exists - skipped." -ForegroundColor Gray
continue
}
New-Item -ItemType Directory -Path $dir | Out-Null
New-Item -ItemType Directory -Path (Join-Path $dir "Archive sent to FTP") | Out-Null
New-Item -ItemType Directory -Path (Join-Path $dir "Patient\Export\Archive Sent to FTP") -Force | Out-Null
Write-Host " Created $name" -ForegroundColor Green
}
}
function Edit-Entity($entityName) {
$config = Get-EntityConfig $entityName
if (-not $config) {
Write-Host "No entity.json found for $entityName." -ForegroundColor Red
return
}
# A hand-written entity.json may be missing whole sections.
foreach ($section in @("ftps", "sftp")) {
if (-not $config.$section) {
Set-Field $config $section ([PSCustomObject]@{})
}
}
while ($true) {
Write-Header "$entityName - current settings"
Show-Settings $config
$choice = Read-Choice "What do you want to change?" @(
"The daily-upload server (address, protocol, login)",
"Where the daily image files go on the server",
"Where PT STMT files from the dated folder go",
"Patient files (server and folder)",
"Add practice folders",
"Nothing - save and go back"
) 6
switch ($choice) {
1 {
$d = Read-ServerDetails "the daily files" $config.ftps
Set-Field $config.ftps "host" $d.host
Set-Field $config.ftps "protocol" $d.protocol
Set-Field $config.ftps "username" $d.username
Set-Field $config.ftps "password" $d.password
}
2 {
Set-Field $config.ftps "tiff_path" (Read-RemotePath "the daily image files" $config.ftps.tiff_path)
}
3 {
Write-Host ""
if (Read-YesNo "Do PT STMT files show up in the dated folders?" "n") {
Set-Field $config.ftps "pdf_path" (Read-RemotePath "the PT STMT files" $config.ftps.pdf_path)
} else {
Set-Field $config.ftps "pdf_path" $null
Write-Host " PT STMT files from the dated folder will be ignored." -ForegroundColor Gray
}
}
4 {
Write-Host ""
if (Read-YesNo "Does $entityName send patient files?" "n") {
$same = Read-Choice "Which server do the patient files go to?" @(
"The same server as the daily files ($($config.ftps.host))",
"A different server"
) 1
if ($same -eq 1) {
Set-Field $config.sftp "host" $config.ftps.host
Set-Field $config.sftp "protocol" $config.ftps.protocol
Set-Field $config.sftp "username" $config.ftps.username
Set-Field $config.sftp "password" $config.ftps.password
} else {
$p = Read-ServerDetails "the patient files" $config.sftp
Set-Field $config.sftp "host" $p.host
Set-Field $config.sftp "protocol" $p.protocol
Set-Field $config.sftp "username" $p.username
Set-Field $config.sftp "password" $p.password
}
Set-Field $config.sftp "patient_path" (Read-RemotePath "the patient .txt/.tif files" $config.sftp.patient_path)
Set-Field $config "workflow" "insurance+patient"
} else {
Set-Field $config "workflow" "insurance"
Write-Host " Patient files turned off for $entityName." -ForegroundColor Gray
}
}
5 { Add-Practices $entityName }
6 {
$file = Save-EntityConfig $entityName $config
Copy-RunBat $entityName
Write-Host ""
Write-Host "Saved $file" -ForegroundColor Green
return
}
}
Save-EntityConfig $entityName $config | Out-Null
}
}
function Set-Field($object, $name, $value) {
$object | Add-Member -NotePropertyName $name -NotePropertyValue $value -Force
}
function Show-Settings($config) {
$proto = if ($config.ftps.protocol) { $config.ftps.protocol.ToUpper() } else { "(not set)" }
Write-Host ""
Write-Host " Daily upload" -ForegroundColor White
Write-Host " Server : $(Show-Value $config.ftps.host) over $proto"
Write-Host " Login : $(Show-Value $config.ftps.username) / $(Show-Password $config.ftps.password)"
Write-Host " Images to : $(Show-Value $config.ftps.tiff_path)"
Write-Host " PT STMT to: $(Show-Value $config.ftps.pdf_path)"
Write-Host ""
Write-Host " Patient files" -ForegroundColor White
if ($config.workflow -eq "insurance+patient") {
$pproto = if ($config.sftp.protocol) { $config.sftp.protocol.ToUpper() } else { "(not set)" }
Write-Host " Server : $(Show-Value $config.sftp.host) over $pproto"
Write-Host " Login : $(Show-Value $config.sftp.username) / $(Show-Password $config.sftp.password)"
Write-Host " Files to : $(Show-Value $config.sftp.patient_path)"
} else {
Write-Host " Not used for this entity." -ForegroundColor Gray
}
}
function Show-Value($value) {
if ([string]::IsNullOrWhiteSpace([string]$value)) { return "(not set)" }
return [string]$value
}
function Show-Password($value) {
if ([string]::IsNullOrWhiteSpace([string]$value)) { return "(not set)" }
return "*" * ([string]$value).Length
}
# -- Connection test ----------------------------------------------------------
# Works out the protocol exactly the way engine.ps1 does, so a passing test
# here means the real run connects the same way.
function Get-ConnProtocol($conn, $fallback) {
if ($conn.protocol) {
$p = ([string]$conn.protocol).ToLower().Trim()
if ($p -in @("sftp", "ftps", "ftp")) { return $p }
}
if (([string]$conn.host) -imatch '^(\w+)://') {
$scheme = $Matches[1].ToLower()
if ($scheme -in @("sftp", "ftps", "ftp")) { return $scheme }
}
if ($conn.tls -eq $true) { return "ftps" }
return $fallback
}
function Get-SessionOptions($conn, $fallback) {
$opts = New-Object WinSCP.SessionOptions
# Older configs kept the protocol as an "sftp://" prefix on the host.
$opts.HostName = (([string]$conn.host) -replace '^\w+://', '').Split('/')[0].Trim()
$opts.UserName = $conn.username
$opts.Password = $conn.password
$protocol = Get-ConnProtocol $conn $fallback
if ($protocol -eq "sftp") {
$opts.Protocol = [WinSCP.Protocol]::Sftp
$opts.PortNumber = if ($conn.port) { [int]$conn.port } else { 22 }
$opts.GiveUpSecurityAndAcceptAnySshHostKey = $true
} else {
$opts.Protocol = [WinSCP.Protocol]::Ftp
$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
}
}
return $opts
}
function Test-ServerConnection($label, $conn, $pathTemplate, $practices, $fallback) {
Write-Host ""
Write-Host " $label" -ForegroundColor White
if ([string]::IsNullOrWhiteSpace([string]$conn.host)) {
Write-Host " Not set up - nothing to test." -ForegroundColor Gray
return
}
$opts = Get-SessionOptions $conn $fallback
$protocol = (Get-ConnProtocol $conn $fallback).ToUpper()
Write-Host " Connecting to $($opts.HostName) over $protocol as $($conn.username) ... " -NoNewline
$session = New-Object WinSCP.Session
try {
$session.Open($opts)
Write-Host "OK" -ForegroundColor Green
} catch {
Write-Host "FAILED" -ForegroundColor Red
Write-Host " $($_.Exception.Message)" -ForegroundColor Red
Write-Host ""
Write-Host " $(Get-ConnectionHint $_.Exception.Message)" -ForegroundColor Yellow
$session.Dispose()
return
}
if ([string]::IsNullOrWhiteSpace([string]$pathTemplate)) {
Write-Host " No folder path set yet - skipping the folder check." -ForegroundColor Gray
$session.Dispose()
return
}
try {
foreach ($practice in $practices) {
$remote = $pathTemplate -replace '\{practice\}', $practice
Write-Host " $remote ... " -NoNewline
if ($session.FileExists($remote)) {
Write-Host "found" -ForegroundColor Green
} else {
Write-Host "NOT THERE" -ForegroundColor Red
}
}
} catch {
Write-Host "could not check - $($_.Exception.Message)" -ForegroundColor Yellow
} finally {
$session.Dispose()
}
}
function Get-ConnectionHint($message) {
if ($message -imatch "cannot connect to an SFTP server using an FTP") {
return "This server wants SFTP. Run setup again and pick SFTP for this server."
}
if ($message -imatch "cannot connect to an FTP server using an SFTP|expected SSH") {
return "This server wants FTP or FTPS. Run setup again and pick FTPS for this server."
}
if ($message -imatch "authentication|password|login incorrect|530") {
return "The username or password was rejected. Run setup again 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."
}
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 "Write down this message - it says what the server objected to."
}
function Test-Entity($entityName) {
Write-Header "Testing $entityName"
if (-not (Test-Path $winscpDll)) {
Write-Host "WinSCP is not installed yet - run setup.bat first." -ForegroundColor Red
return
}
Get-ChildItem -Path (Join-Path $scriptRoot "WinSCP") -Recurse -File |
Unblock-File -ErrorAction SilentlyContinue
try {
Add-Type -Path $winscpDll
} catch {
Write-Host "Could not load WinSCP - $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Try deleting the WinSCP folder and running setup.bat again." -ForegroundColor Yellow
return
}
$config = Get-EntityConfig $entityName
if (-not $config) {
Write-Host "No entity.json found for $entityName." -ForegroundColor Red
return
}
$export1 = Join-Path (Join-Path $entitiesDir $entityName) "Export1"
$practices = @()
if (Test-Path $export1) {
$practices = @(Get-ChildItem -Path $export1 -Directory | ForEach-Object { $_.Name })
}
if ($practices.Count -eq 0) {
Write-Host "No practice folders yet - testing the login only." -ForegroundColor Gray
}
Test-ServerConnection "Daily upload" $config.ftps $config.ftps.tiff_path $practices "ftp"
if ($config.ftps.pdf_path) {
Test-ServerConnection "PT STMT folders" $config.ftps $config.ftps.pdf_path $practices "ftp"
}
if ($config.workflow -eq "insurance+patient") {
Test-ServerConnection "Patient files" $config.sftp $config.sftp.patient_path $practices "sftp"
}
Write-Host ""
Write-Host "A folder marked NOT THERE means the upload will fail for that" -ForegroundColor Yellow
Write-Host "practice. Either the path is wrong, or the partner has not made" -ForegroundColor Yellow
Write-Host "the folder yet - check the path first, then ask them." -ForegroundColor Yellow
}
# -- Main menu ----------------------------------------------------------------
function Select-Entity($purpose) {
$names = Get-EntityNames
if ($names.Count -eq 0) {
Write-Host ""
Write-Host "No entities set up yet - pick 'Set up a new entity' first." -ForegroundColor Yellow
return $null
}
$choice = Read-Choice "Which entity do you want to $purpose" $names 1
return $names[$choice - 1]
}
Write-Header "Melissa Uploader - Setup"
Write-Host ""
Write-Host "This asks a few questions and writes the settings files for you." -ForegroundColor Gray
Write-Host "You never have to open one by hand." -ForegroundColor Gray
while ($true) {
$names = Get-EntityNames
Write-Host ""
if ($names.Count -gt 0) {
Write-Host "Entities set up so far: $($names -join ', ')" -ForegroundColor Gray
} else {
Write-Host "No entities set up yet." -ForegroundColor Gray
}
$choice = Read-Choice "What do you want to do?" @(
"Set up a new entity",
"Change an entity's settings",
"Test an entity's connection and folders",
"Quit"
) 4
switch ($choice) {
1 { New-Entity }
2 { $name = Select-Entity "change"; if ($name) { Edit-Entity $name } }
3 { $name = Select-Entity "test"; if ($name) { Test-Entity $name } }
4 {
Write-Host ""
Write-Host "Done. Double-click run-all.bat when you are ready to upload." -ForegroundColor Green
Write-Host ""
exit 0
}
}
}