# 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_\ - 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 } } }