diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..76d943b --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Never commit credentials or uploaded files +Entities/*/entity.json +Entities/*/Export1/*/practice.json +Entities/*/Export1/*/ +WinSCP/ +*.pdf +*.tif +*.tiff +*.txt + +# Keep example templates +!Entities/EXAMPLE/entity.json +!Entities/EXAMPLE/Export1/PRACTICE_NAME/practice.json diff --git a/Entities/EXAMPLE/Export1/PRACTICE_NAME/practice.json b/Entities/EXAMPLE/Export1/PRACTICE_NAME/practice.json new file mode 100644 index 0000000..8f2f81f --- /dev/null +++ b/Entities/EXAMPLE/Export1/PRACTICE_NAME/practice.json @@ -0,0 +1,13 @@ +{ + "_comment": "Only create this file if this practice has its own FTP login. Delete any section you don't need to override.", + + "ftps": { + "username": "practice-specific-username", + "password": "practice-specific-password" + }, + + "sftp": { + "username": "practice-specific-username", + "password": "practice-specific-password" + } +} diff --git a/Entities/EXAMPLE/entity.json b/Entities/EXAMPLE/entity.json new file mode 100644 index 0000000..0561842 --- /dev/null +++ b/Entities/EXAMPLE/entity.json @@ -0,0 +1,21 @@ +{ + "workflow": "insurance+patient", + + "ftps": { + "host": "ftp.example.com", + "port": 21, + "tls": true, + "username": "shared-username", + "password": "shared-password", + "insurance_path": "/{practice}/Insurance", + "pdf_path": "/{practice}/PDF" + }, + + "sftp": { + "host": "sftp.example.com", + "port": 22, + "username": "shared-username", + "password": "shared-password", + "patient_path": "/{practice}/Patient" + } +} diff --git a/README.md b/README.md index 52d2a37..547944f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,90 @@ -# melissa-uploader +# Melissa Uploader -Automated FTPS/SFTP upload tool for practice exports \ No newline at end of file +Automated upload tool for practice exports. Uploads insurance PDFs and patient files to the correct FTP/SFTP servers for each practice, then archives what was sent. + +--- + +## First-time setup + +1. Double-click **setup.bat** — this downloads WinSCP (needed for uploading) +2. Set up your entity folders (see below) +3. Double-click **run-all.bat** each day after running the PT STMT rename script + +--- + +## Daily workflow + +1. Run the **PT STMT rename script** manually (as usual) +2. Double-click **run-all.bat** to upload everything +3. Check the window for any failures — failed files stay in place for retry + +--- + +## Folder layout + +``` +run-all.bat <- double-click to run everything +engine.ps1 +setup.bat +WinSCP\ <- created by setup.bat automatically +Entities\ + CAMBS\ + entity.json <- server details for this entity + run.bat <- double-click to run just CAMBS + Export1\ + AJMAT\ + practice.json <- only needed if AJMAT has its own FTP login + 20260629\ <- today's insurance PDFs go here + Archive sent to FTP\ <- uploaded date folders move here + Patient\ + Export\ + PT STMT_20260629\ <- created by rename script + Archive Sent to FTP\ <- uploaded patient folders move here + CIIR\ + ... + CONSensio\ + entity.json + ... +``` + +--- + +## Setting up a new entity + +1. Create a folder under `Entities\` with the entity name (e.g. `Entities\CAMBS\`) +2. Copy `run.bat` into it +3. Create `entity.json` in that folder — copy from `Entities\EXAMPLE\entity.json` and fill in: + +| Setting | What to put | +|---------|------------| +| `workflow` | `insurance` if no patient files, `insurance+patient` if both | +| `ftps.host` | FTP server address | +| `ftps.username` | FTP username (shared across all practices) | +| `ftps.password` | FTP password | +| `ftps.tls` | `true` for FTPS (secure), `false` for plain FTP | +| `ftps.insurance_path` | Remote path for insurance PDFs — keep `{practice}` in it | +| `ftps.pdf_path` | Remote path for PT STMT PDFs — keep `{practice}` in it | +| `sftp.host` | SFTP server (only needed if workflow is `insurance+patient`) | +| `sftp.username` | SFTP username | +| `sftp.password` | SFTP password | +| `sftp.patient_path` | Remote path for patient files — keep `{practice}` in it | + +4. Create the `Export1\` folder and a subfolder for each practice inside the entity folder + +--- + +## Setting up a practice with its own FTP login + +If one practice has its own FTP username/password (different from the entity default): + +1. Create a `practice.json` file inside that practice's folder +2. Copy from `Entities\EXAMPLE\Export1\PRACTICE_NAME\practice.json` +3. Fill in only the username and password that differ — everything else comes from entity.json + +--- + +## Something went wrong? + +- **Files failed to upload** — they stay in the date folder. Fix the issue and run again. +- **A date folder is still there after running** — one or more files failed. Check the output window. +- **WinSCP error on startup** — run `setup.bat` again to re-download WinSCP. diff --git a/engine.ps1 b/engine.ps1 new file mode 100644 index 0000000..0b3e7bc --- /dev/null +++ b/engine.ps1 @@ -0,0 +1,236 @@ +# Core upload engine — called by run.ps1 with an entity directory path +param( + [Parameter(Mandatory=$true)] + [string]$EntityDir +) + +$scriptRoot = Split-Path -Parent $PSCommandPath +$winscpDll = Join-Path $scriptRoot "WinSCP\WinSCPnet.dll" +$today = Get-Date -Format "yyyyMMdd" + +if (-not (Test-Path $winscpDll)) { + Write-Host "" + Write-Host "ERROR: WinSCP not found. Run setup.bat first." -ForegroundColor Red + exit 1 +} +Add-Type -Path $winscpDll + +$entityConfigFile = Join-Path $EntityDir "entity.json" +if (-not (Test-Path $entityConfigFile)) { + Write-Host "ERROR: entity.json not found in $EntityDir" -ForegroundColor Red + exit 1 +} + +$entityConfig = Get-Content $entityConfigFile -Raw | ConvertFrom-Json +$workflow = $entityConfig.workflow +$export1 = Join-Path $EntityDir "Export1" +$entityName = Split-Path -Leaf $EntityDir + +if (-not (Test-Path $export1)) { + Write-Host "ERROR: Export1 folder not found in $EntityDir" -ForegroundColor Red + exit 1 +} + +Write-Host "" +Write-Host "Entity: $entityName | Workflow: $workflow | Date: $today" -ForegroundColor Cyan +Write-Host ("=" * 60) + +# Merge a practice-level override on top of the entity config +function Merge-Config($base, $override) { + if (-not $override) { return $base } + $json = $base | ConvertTo-Json -Depth 10 + $merged = $json | ConvertFrom-Json + foreach ($section in $override.PSObject.Properties) { + if ($null -ne $merged.PSObject.Properties[$section.Name]) { + foreach ($prop in $section.Value.PSObject.Properties) { + $merged.$($section.Name).$($prop.Name) = $prop.Value + } + } + } + return $merged +} + +# Replace {practice} token in a remote path template +function Expand-Path($template, $practice) { + return $template -replace '\{practice\}', $practice +} + +# Find archive subfolder by name (case-insensitive), create if missing +function Get-ArchiveDir($parent, $name) { + $found = Get-ChildItem -Path $parent -Directory | + Where-Object { $_.Name -ieq $name } | + Select-Object -First 1 + if ($found) { return $found.FullName } + $path = Join-Path $parent $name + New-Item -ItemType Directory -Path $path | Out-Null + return $path +} + +# Upload a list of files to a remote path via FTPS/FTP using WinSCP +function Invoke-FTPSUpload($ftpConfig, $files, $remotePath) { + $opts = New-Object WinSCP.SessionOptions + $opts.Protocol = [WinSCP.Protocol]::Ftp + $opts.FtpSecure = if ($ftpConfig.tls) { [WinSCP.FtpSecure]::Explicit } else { [WinSCP.FtpSecure]::None } + $opts.HostName = $ftpConfig.host + $opts.PortNumber = if ($ftpConfig.port) { [int]$ftpConfig.port } else { 21 } + $opts.UserName = $ftpConfig.username + $opts.Password = $ftpConfig.password + $opts.GiveUpSecurityAndAcceptAnyTlsHostCertificate = $true + + $session = New-Object WinSCP.Session + $ok = 0; $fail = 0 + try { + $session.Open($opts) + foreach ($file in $files) { + $remote = $remotePath.TrimEnd("/") + "/" + $file.Name + try { + $result = $session.PutFiles($file.FullName, $remote) + $result.Check() + Write-Host " $($file.Name) ... ok" -ForegroundColor Green + $ok++ + } catch { + Write-Host " $($file.Name) ... FAILED: $($_.Exception.Message)" -ForegroundColor Red + $fail++ + } + } + } finally { + $session.Dispose() + } + return @{ Ok = $ok; Fail = $fail } +} + +# Upload a list of files to a remote path via SFTP using WinSCP +function Invoke-SFTPUpload($sftpConfig, $files, $remotePath) { + $opts = New-Object WinSCP.SessionOptions + $opts.Protocol = [WinSCP.Protocol]::Sftp + $opts.HostName = $sftpConfig.host + $opts.PortNumber = if ($sftpConfig.port) { [int]$sftpConfig.port } else { 22 } + $opts.UserName = $sftpConfig.username + $opts.Password = $sftpConfig.password + $opts.GiveUpSecurityAndAcceptAnySshHostKey = $true + + $session = New-Object WinSCP.Session + $ok = 0; $fail = 0 + try { + $session.Open($opts) + foreach ($file in $files) { + $remote = $remotePath.TrimEnd("/") + "/" + $file.Name + try { + $result = $session.PutFiles($file.FullName, $remote) + $result.Check() + Write-Host " $($file.Name) ... ok" -ForegroundColor Green + $ok++ + } catch { + Write-Host " $($file.Name) ... FAILED: $($_.Exception.Message)" -ForegroundColor Red + $fail++ + } + } + } finally { + $session.Dispose() + } + return @{ Ok = $ok; Fail = $fail } +} + +$totalOk = 0; $totalFail = 0 +$practices = Get-ChildItem -Path $export1 -Directory + +if ($practices.Count -eq 0) { + Write-Host "No practice folders found in Export1." -ForegroundColor Yellow + exit 0 +} + +foreach ($practice in $practices) { + $pracName = $practice.Name + Write-Host "" + Write-Host " [ $pracName ]" -ForegroundColor Cyan + + # Load optional practice-level credential override + $overrideFile = Join-Path $practice.FullName "practice.json" + $override = if (Test-Path $overrideFile) { Get-Content $overrideFile -Raw | ConvertFrom-Json } else { $null } + $config = Merge-Config $entityConfig $override + + # ── INSURANCE + PT STMT → FTPS ────────────────────────────────────────── + $dateDir = Join-Path $practice.FullName $today + if (-not (Test-Path $dateDir)) { + Write-Host " No $today\ folder — skipping insurance upload." -ForegroundColor Gray + } else { + $allPdfs = Get-ChildItem -Path $dateDir -Filter "*.pdf" -File + $ptStmts = $allPdfs | Where-Object { $_.Name -imatch 'pt[\s_]*stmt' } + $insFiles = $allPdfs | Where-Object { $_.Name -notmatch 'pt[\s_]*stmt' } + + $insPath = Expand-Path $config.ftps.insurance_path $pracName + $pdfPath = Expand-Path $config.ftps.pdf_path $pracName + $insResult = @{ Ok = 0; Fail = 0 } + + if ($insFiles.Count -gt 0) { + Write-Host " Insurance ($($insFiles.Count)) → $insPath" -ForegroundColor White + $r = Invoke-FTPSUpload $config.ftps $insFiles $insPath + $insResult.Ok += $r.Ok; $insResult.Fail += $r.Fail + } + if ($ptStmts.Count -gt 0) { + Write-Host " PT STMT PDF ($($ptStmts.Count)) → $pdfPath" -ForegroundColor White + $r = Invoke-FTPSUpload $config.ftps $ptStmts $pdfPath + $insResult.Ok += $r.Ok; $insResult.Fail += $r.Fail + } + + $totalUploaded = $insFiles.Count + $ptStmts.Count + if ($insResult.Fail -eq 0 -and $totalUploaded -gt 0) { + $archive = Get-ArchiveDir $practice.FullName "Archive sent to FTP" + Move-Item -Path $dateDir -Destination (Join-Path $archive $today) -Force + Write-Host " $today\ archived." -ForegroundColor Green + } elseif ($insResult.Fail -gt 0) { + Write-Host " $($insResult.Fail) failed — $today\ left in place for retry." -ForegroundColor Yellow + } elseif ($totalUploaded -eq 0) { + Write-Host " No PDFs found in $today\." -ForegroundColor Gray + } + + $totalOk += $insResult.Ok; $totalFail += $insResult.Fail + } + + # ── PATIENT FILES → SFTP (only if workflow = insurance+patient) ────────── + if ($workflow -eq "insurance+patient") { + $patientExport = Join-Path $practice.FullName "Patient\Export" + if (-not (Test-Path $patientExport)) { + Write-Host " No Patient\Export folder — skipping patient upload." -ForegroundColor Gray + } else { + $ptFolder = Get-ChildItem -Path $patientExport -Directory | + Where-Object { $_.Name -imatch "PT STMT_$today" } | + Select-Object -First 1 + + if (-not $ptFolder) { + Write-Host " No PT STMT_$today folder — skipping patient upload." -ForegroundColor Gray + } else { + $patFiles = Get-ChildItem -Path $ptFolder.FullName -File | + Where-Object { $_.Extension -imatch '\.(txt|tif|tiff)$' } + + if ($patFiles.Count -eq 0) { + Write-Host " No txt/tif files in $($ptFolder.Name)\ — skipping." -ForegroundColor Gray + } else { + $patPath = Expand-Path $config.sftp.patient_path $pracName + Write-Host " Patient ($($patFiles.Count)) → $patPath" -ForegroundColor White + $r = Invoke-SFTPUpload $config.sftp $patFiles $patPath + + if ($r.Fail -eq 0) { + $patArchive = Get-ArchiveDir $patientExport "Archive Sent to FTP" + Move-Item -Path $ptFolder.FullName -Destination (Join-Path $patArchive $ptFolder.Name) -Force + Write-Host " $($ptFolder.Name)\ archived." -ForegroundColor Green + } else { + Write-Host " $($r.Fail) failed — left in place for retry." -ForegroundColor Yellow + } + + $totalOk += $r.Ok; $totalFail += $r.Fail + } + } + } + } +} + +Write-Host "" +Write-Host ("=" * 60) +if ($totalFail -eq 0) { + Write-Host "Done: $totalOk file(s) uploaded for $entityName." -ForegroundColor Green +} else { + Write-Host "Done: $totalOk uploaded, $totalFail failed." -ForegroundColor Yellow +} + +exit $(if ($totalFail -gt 0) { 1 } else { 0 }) diff --git a/run-all.bat b/run-all.bat new file mode 100644 index 0000000..4140075 --- /dev/null +++ b/run-all.bat @@ -0,0 +1,3 @@ +@echo off +powershell.exe -ExecutionPolicy Bypass -File "%~dp0run-all.ps1" +pause diff --git a/run-all.ps1 b/run-all.ps1 new file mode 100644 index 0000000..143aaf1 --- /dev/null +++ b/run-all.ps1 @@ -0,0 +1,31 @@ +# Run uploads for ALL entities under the Entities\ folder +$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$entitiesDir = Join-Path $scriptRoot "Entities" + +if (-not (Test-Path $entitiesDir)) { + Write-Host "ERROR: Entities\ folder not found." -ForegroundColor Red + exit 1 +} + +$entities = Get-ChildItem -Path $entitiesDir -Directory | + Where-Object { Test-Path (Join-Path $_.FullName "entity.json") } + +if ($entities.Count -eq 0) { + Write-Host "No entity folders with entity.json found under Entities\." -ForegroundColor Yellow + exit 0 +} + +Write-Host "Found $($entities.Count) entity/entities to process." + +$anyFail = $false +foreach ($entity in $entities) { + & powershell.exe -ExecutionPolicy Bypass -File (Join-Path $scriptRoot "engine.ps1") -EntityDir $entity.FullName + if ($LASTEXITCODE -ne 0) { $anyFail = $true } +} + +Write-Host "" +if (-not $anyFail) { + Write-Host "All entities complete." -ForegroundColor Green +} else { + Write-Host "One or more entities had failures — check output above." -ForegroundColor Yellow +} diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..79d64f5 --- /dev/null +++ b/run.bat @@ -0,0 +1,5 @@ +@echo off +REM Place a copy of this file inside an Entity folder (e.g. Entities\CAMBS\) +REM to run just that entity's upload. +powershell.exe -ExecutionPolicy Bypass -File "%~dp0..\..\engine.ps1" -EntityDir "%~dp0" +pause diff --git a/setup.bat b/setup.bat new file mode 100644 index 0000000..7822bf1 --- /dev/null +++ b/setup.bat @@ -0,0 +1,5 @@ +@echo off +echo Downloading WinSCP... +powershell.exe -ExecutionPolicy Bypass -Command ^ + "$url = 'https://winscp.net/download/WinSCP-6.3.6-Automation.zip'; $zip = '%TEMP%\winscp.zip'; $dest = '%~dp0WinSCP'; Invoke-WebRequest -Uri $url -OutFile $zip; Expand-Archive -Path $zip -DestinationPath $dest -Force; Write-Host 'WinSCP ready.' -ForegroundColor Green" +pause