# Print / DICOM Relay — Implementation Detail ## PHP Source: `send_rimage_multiple.php` The original script has two distinct phases: **Phase 1 — DICOM Fetch** (identical to mkiso_multiple.php): 1. Parse comma-separated `accession_number` from GET 2. DB lookup on HIS DB (`rsabt201107`): - `pacs_result_series` → MEDRECID, RegID (using first accession) - `medrec` → NamaPasien 3. Create `/tmp/dicomdir_/DICOMDIR` 4. Copy microdicom viewer files 5. Nested loop: 14 modalities × N accessions → Java dcmqr C-MOVE **Phase 2 — Relay to CD Publisher** (unique to this script): 6. `dcmsend +sd +rd 172.16.0.120 104 $dicomdir/DICOMDIR` 7. **No genisoimage, no download streaming** 8. **No cleanup** (temp dir is leaked — Go version MUST fix this) 9. **Bogus headers**: Script sets `Content-Type: application/octet-stream` and `Content-Disposition` with patient-name filename, but never calls `readfile()`. The response body is empty (or dcmsend stdout). --- ## Go Implementation ### Service: `internal/service/relay.go` ```go package service import ( "context" "fmt" "log/slog" "mkiso-server/pkg/dicom" ) type RelayService struct { dicomSvc *DicomService patientRepo *repo.PatientRepo // <-- NEW: patient API client cdHost string cdPort int ourAETitle string storescuBin string microdicomSrc string logger *slog.Logger } type RelayResult struct { AccessionsSent []string `json:"accessions_sent"` PatientName string `json:"patient_name"` // from patient API Destination string `json:"destination"` FilesSent int `json:"files_sent"` } func (s *RelayService) RelayToCDPublisher( ctx context.Context, accessionNumbers []string, ) (*RelayResult, error) { // Step 1: Validate accession via patient API (replaces DB lookup) // Use first accession to get patient context patient, err := s.patientRepo.ByAccessionNumber(ctx, accessionNumbers[0]) if err != nil { return nil, fmt.Errorf("patient API lookup for %q: %w", accessionNumbers[0], err) } s.logger.Info("patient data retrieved for relay", "accession", accessionNumbers[0], "patient", patient.PatientName, ) // Step 2: Create temp dir tempDir, err := os.MkdirTemp(cfg.TempDir, "dicomdir_") if err != nil { return nil, fmt.Errorf("create temp dir: %w", err) } defer os.RemoveAll(tempDir) // GUARANTEED cleanup (fixes PHP leak) dicomDir := filepath.Join(tempDir, "DICOMDIR") if err := os.MkdirAll(dicomDir, 0755); err != nil { return nil, fmt.Errorf("create DICOMDIR: %w", err) } // Copy microdicom viewer files if err := copyMicrodicom(s.microdicomSrc, tempDir); err != nil { return nil, fmt.Errorf("copy microdicom: %w", err) } // Fetch DICOM from PACS (same as download flow) filesRetrieved, err := s.dicomSvc.FetchDICOMMultiple(ctx, accessionNumbers, dicomDir) if err != nil { return nil, fmt.Errorf("DICOM fetch failed for %v: %w", accessionNumbers, err) } s.logger.Info("DICOM fetched for relay", "accessions", accessionNumbers, "files", filesRetrieved, "dir", dicomDir, ) // Relay to CD Publisher via storescu destination := fmt.Sprintf("%s:%d", s.cdHost, s.cdPort) exitCode, stdout, stderr, err := dicom.RunStoreSCU( ctx, s.storescuBin, s.ourAETitle, s.cdHost, s.cdPort, dicomDir, ) if err != nil { return nil, fmt.Errorf("storescu failed: %w", err) } if exitCode != 0 { return nil, fmt.Errorf("storescu exit %d: %s", exitCode, stderr) } s.logger.Info("DICOM relayed to CD Publisher", "accessions", accessionNumbers, "destination", destination, "files", filesRetrieved, ) return &RelayResult{ AccessionsSent: accessionNumbers, PatientName: patient.PatientName, Destination: destination, FilesSent: filesRetrieved, }, nil } ``` ### DICOM Command: `pkg/dicom/command.go` — add `RunStoreSCU` ```go // RunStoreSCU sends DICOM files to a remote AE via C-STORE. // Equivalent to: storescu -aet +sd +r func RunStoreSCU( ctx context.Context, storescuBin, aeTitle, host string, port int, dir string, ) (exitCode int, stdout, stderr string, err error) { cmd := exec.CommandContext(ctx, storescuBin, "-aet", aeTitle, "+sd", // scan directories "+r", // recurse host, strconv.Itoa(port), dir, ) var outBuf, errBuf bytes.Buffer cmd.Stdout = &outBuf cmd.Stderr = &errBuf err = cmd.Run() stdout = outBuf.String() stderr = errBuf.String() if err != nil { if exitErr, ok := err.(*exec.ExitError); ok { exitCode = exitErr.ExitCode() } else { exitCode = -1 } return exitCode, stdout, stderr, err } return 0, stdout, stderr, nil } ``` ### Handler: `internal/handler/print.go` ```go func PrintISO(relaySvc *service.RelayService) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { acc := r.URL.Query().Get("accession_number") if acc == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing accession_number"}) return } result, err := relaySvc.RelayToCDPublisher(r.Context(), []string{acc}) if err != nil { slog.Error("print ISO failed", "accession", acc, "error", err) status := http.StatusBadGateway if strings.Contains(err.Error(), "not found") { status = http.StatusNotFound } writeJSON(w, status, map[string]string{ "error": "DICOM relay failed", "destination": fmt.Sprintf("%s:%d", cfg.CDPublisher.Host, cfg.CDPublisher.Port), "detail": err.Error(), }) return } writeJSON(w, http.StatusOK, map[string]interface{}{ "status": "ok", "accessions_sent": result.AccessionsSent, "patient_name": result.PatientName, "destination": result.Destination, "files_sent": result.FilesSent, }) } } func PrintISOMultiple(relaySvc *service.RelayService) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { raw := r.URL.Query().Get("accession_numbers") if raw == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing accession_numbers"}) return } accs := strings.Split(raw, ",") for i, a := range accs { accs[i] = strings.TrimSpace(a) if accs[i] == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "empty accession_number in list"}) return } } result, err := relaySvc.RelayToCDPublisher(r.Context(), accs) if err != nil { slog.Error("print ISO multiple failed", "accessions", accs, "error", err) writeJSON(w, http.StatusBadGateway, map[string]string{ "error": "DICOM relay failed", "destination": fmt.Sprintf("%s:%d", cfg.CDPublisher.Host, cfg.CDPublisher.Port), "detail": err.Error(), }) return } writeJSON(w, http.StatusOK, map[string]interface{}{ "status": "ok", "accessions_sent": result.AccessionsSent, "destination": result.Destination, "files_sent": result.FilesSent, }) } } ``` ### Config Additions ```yaml # config.yaml — new cd_publisher block cd_publisher: host: "172.16.0.120" port: 104 ``` ### Config Struct: `internal/config/config.go` ```go type Config struct { // ... existing fields ... CDPublisher CDPublisherConfig `yaml:"cd_publisher"` } type CDPublisherConfig struct { Host string `yaml:"host"` Port int `yaml:"port"` } ``` --- ## Key Differences from Download Flow | Aspect | Download Multiple | Print/Relay | |--------|-------------------|-------------| | Output | ISO file streamed to browser | DICOM sent to CD Publisher | | Response | `Content-Type: application/octet-stream` | `Content-Type: application/json` | | genisoimage | ✅ Required | ❌ Not used | | storescu | ❌ Not used | ✅ Required (to CD Publisher) | | Patient API | ✅ For filename | ✅ For validation + response context | | Filename | `{name}-{accs}.iso` | N/A (JSON response) | | Cleanup | After streaming completes | After relay completes | --- ## storescu Command Replaces `dcmsend +sd +rd 172.16.0.120 104 $dicomdir/DICOMDIR`: ```bash /data/dcmtk-bin/storescu \ -aet CDRECORD \ +sd \ # scan directories +r \ # recurse 172.16.0.120 104 \ /tmp/dicomdir_xxx/DICOMDIR ``` | Flag | Purpose | |------|---------| | `-aet CDRECORD` | Our AE title (calling) | | `+sd` | Scan subdirectories for DICOM files | | `+r` | Recurse into subdirectories | | `172.16.0.120 104` | CD Publisher host:port | | `/tmp/dicomdir_xxx/DICOMDIR` | Directory containing DICOM files | --- ## Error Cases | Scenario | Response | |----------|----------| | Invalid accession (patient API 404) | `404 {"error":"accession_number not found"}` | | Patient API unavailable | `502 {"error":"patient API unavailable"}` | | CD Publisher unreachable | `502 {"error":"CD Publisher unreachable","destination":"..."}` | | CD Publisher rejects association | `502 {"error":"DICOM relay failed","detail":"association rejected"}` | | DICOM fetch failed (PACS down) | `502 {"error":"PACS unreachable"}` | | No DICOM files fetched | `502 {"error":"no DICOM data for accession_number"}` | | Partial relay (some files failed) | storescu may return 0 but log warnings — Go should parse stderr | --- ## Integration with HIS Frontend The `pacs_downloadiso` module JS calls: ```javascript // printiso() in index.php window.open("http://"+PACS_HOST+"/send_rimage_multiple.php?accession_number="+AccessionNumber); // fn_printisomultiple() in index.php window.open("http://"+PACS_HOST+"/send_rimage_multiple.php?accession_number="+an); ``` The Go API returns **JSON**, not an HTML page. The HIS JS uses `window.open()` — it will open a new tab/window showing the JSON. **Recommendation:** The nginx proxy should handle this. Or the HIS module could be updated to use AJAX instead of `window.open()` for print. For now, opening a JSON tab is acceptable (shows success/failure to the operator). Better: if the `window.open` approach is kept, the Go handler could return a simple HTML page instead of raw JSON for the print endpoint (check `Accept` header or add a `?format=html` query param). --- ## Verification ```bash # Test single accession relay curl -H "Authorization: Bearer $TOKEN" \ "http://localhost:8080/api/iso/print?accession_number=MR.2024.001" # Expected: {"status":"ok","accessions_sent":["MR.2024.001"],"patient_name":"JOHN DOE","destination":"172.16.0.120:104","files_sent":42} # Test multiple accession relay curl -H "Authorization: Bearer $TOKEN" \ "http://localhost:8080/api/iso/print-multiple?accession_numbers=MR.001,CT.002" # Test error: invalid accession (patient API returns 404) # Expected: 404 {"error":"accession_number not found"} # Test error: CD Publisher offline # Expected: 502 {"error":"CD Publisher unreachable"} # Test error: patient API unavailable # Expected: 502 {"error":"patient API unavailable"} # Test storescu directly (dev workstation) storescu -aet CDRECORD +sd +r 172.16.0.120 104 /tmp/test_dicom/DICOMDIR ```