# 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_<uniqid>/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 <ae> +sd +r <host> <port> <dir>
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
```
