# Zoho Form → Domain Config Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Add a `POST /update-domain` endpoint to the webresponder Flask app so Zoho Forms can submit domain config data that is immediately written to `domains.json`.

**Architecture:** New route in `app.py` validates a shared secret and required fields, then atomically upserts the domain entry in `domains.json` using `os.replace()` on a temp file. `UPDATE_SECRET` is loaded from `.env` at startup alongside `NS_TOKEN`. No restart needed — `load_domains()` already re-reads the file on every request.

**Tech Stack:** Python 3, Flask, `os.replace()` for atomic writes, `pytest` + `unittest.mock`.

---

## Reference: conventions to follow

- Route errors return JSON `{"status": "error", "message": "..."}` with appropriate HTTP status — same as `_ctc_error()` in `click_to_call`.
- `load_domains()` reads `domains.json` on every call — reuse it.
- `_DOMAIN_RE` validates domain format — reuse it.
- All test files set `NS_TOKEN` via `tests/conftest.py` before importing `app`.
- Mock `app.load_domains` and file I/O in tests; never touch the real `domains.json`.

---

### Task 1: Add `UPDATE_SECRET` to `.env.example` and load it in `app.py`

**Files:**
- Modify: `Netsapiens-Scripts/webresponder/.env.example`
- Modify: `Netsapiens-Scripts/webresponder/app.py`

**Step 1: Update `.env.example`**

Add the new variable so it's documented:

```
NS_TOKEN=your-super-user-bearer-token-here
UPDATE_SECRET=your-random-secret-here
```

Generate a real value for the server later with:
```bash
python3 -c "import secrets; print(secrets.token_hex(32))"
```

**Step 2: Load `UPDATE_SECRET` in `app.py`**

Add after the `NS_TOKEN` block (around line 27):

```python
UPDATE_SECRET = os.environ.get("UPDATE_SECRET", "")
if not UPDATE_SECRET:
    raise RuntimeError("UPDATE_SECRET environment variable is not set")
```

**Step 3: Add `UPDATE_SECRET` to `tests/conftest.py` so tests don't fail at import**

The file currently sets only `NS_TOKEN`. Add `UPDATE_SECRET`:

```python
import os

os.environ.setdefault("NS_TOKEN", "test-token-for-pytest")
os.environ.setdefault("UPDATE_SECRET", "test-secret-for-pytest")
```

**Step 4: Run the existing test suite to make sure nothing broke**

```bash
cd Netsapiens-Scripts/webresponder && venv/bin/pytest -v
```
Expected: all existing tests PASS.

**Step 5: Commit**

```bash
git add Netsapiens-Scripts/webresponder/.env.example \
        Netsapiens-Scripts/webresponder/app.py \
        Netsapiens-Scripts/webresponder/tests/conftest.py
git commit -m "feat(update-domain): load UPDATE_SECRET env var at startup"
```

---

### Task 2: `save_domains()` atomic write helper — write failing test

**Files:**
- Test: `Netsapiens-Scripts/webresponder/tests/test_update_domain.py` (new file)
- Modify: `Netsapiens-Scripts/webresponder/app.py`

**Step 1: Write the failing test**

Create `tests/test_update_domain.py`:

```python
import json
import os
import tempfile
from pathlib import Path
from unittest.mock import patch, mock_open, MagicMock

from app import save_domains


def test_save_domains_writes_valid_json(tmp_path):
    domains_file = tmp_path / "domains.json"
    data = {"example.com": {"api_domain": "example.com"}}

    with patch("app.DOMAINS_FILE", domains_file):
        save_domains(data)

    written = json.loads(domains_file.read_text())
    assert written == data


def test_save_domains_is_atomic(tmp_path):
    """Writes to a .tmp file then replaces — never leaves a partial file."""
    domains_file = tmp_path / "domains.json"
    data = {"example.com": {}}
    tmp_file = Path(str(domains_file) + ".tmp")

    with patch("app.DOMAINS_FILE", domains_file):
        save_domains(data)

    # The .tmp file should be gone after a successful write
    assert not tmp_file.exists()
    assert domains_file.exists()


def test_save_domains_pretty_prints():
    """Output should be indented JSON, not a single line."""
    with tempfile.TemporaryDirectory() as td:
        domains_file = Path(td) / "domains.json"
        with patch("app.DOMAINS_FILE", domains_file):
            save_domains({"a.com": {}})
        content = domains_file.read_text()
    assert "\n" in content  # indented, not a single line
```

**Step 2: Run test to verify it fails**

```bash
cd Netsapiens-Scripts/webresponder && venv/bin/pytest tests/test_update_domain.py -v
```
Expected: FAIL with `ImportError: cannot import name 'save_domains'`

**Step 3: Write minimal implementation**

In `app.py`, add after `load_domains()`:

```python
def save_domains(data):
    """Atomically write domains data to DOMAINS_FILE."""
    tmp = Path(str(DOMAINS_FILE) + ".tmp")
    tmp.write_text(json.dumps(data, indent=2))
    os.replace(tmp, DOMAINS_FILE)
```

Note: `os.replace` is already available via the standard `os` import at the top of `app.py`.

**Step 4: Run test to verify it passes**

```bash
cd Netsapiens-Scripts/webresponder && venv/bin/pytest tests/test_update_domain.py -v
```
Expected: PASS (3 passed)

**Step 5: Commit**

```bash
git add Netsapiens-Scripts/webresponder/app.py \
        Netsapiens-Scripts/webresponder/tests/test_update_domain.py
git commit -m "feat(update-domain): add atomic save_domains() helper"
```

---

### Task 3: `POST /update-domain` happy path — write failing test

**Files:**
- Test: `Netsapiens-Scripts/webresponder/tests/test_update_domain.py`
- Modify: `Netsapiens-Scripts/webresponder/app.py`

**Step 1: Write the failing test**

Append to `tests/test_update_domain.py`:

```python
import pytest
from app import app

VALID_PAYLOAD = {
    "update_secret": "test-secret-for-pytest",
    "domain": "acme.com",
    "api_domain": "acme.com",
    "user": "2000",
    "from_number": "18135550001",
    "message": "Hours: M-F 9am-5pm",
    "queue": "500@acme.com",
    "button_text": "Call Us",
    "button_color": "#ff0000",
    "company_name": "Acme Corp",
    "logo_url": "",
}

EXISTING_DOMAINS = {
    "existing.com": {"api_domain": "existing.com", "user": "1000"}
}


@pytest.fixture
def ud_client():
    app.testing = True
    return app.test_client()


def _post(client, payload=None):
    return client.post(
        "/update-domain",
        data=payload or VALID_PAYLOAD,
    )


def test_update_domain_happy_path_returns_200(ud_client):
    with patch("app.load_domains", return_value=dict(EXISTING_DOMAINS)), \
         patch("app.save_domains") as mock_save:
        resp = _post(ud_client)
    assert resp.status_code == 200
    assert resp.get_json() == {"status": "ok", "domain": "acme.com"}


def test_update_domain_upserts_new_domain(ud_client):
    captured = {}

    def fake_save(data):
        captured.update(data)

    with patch("app.load_domains", return_value=dict(EXISTING_DOMAINS)), \
         patch("app.save_domains", side_effect=fake_save):
        _post(ud_client)

    assert "acme.com" in captured
    assert captured["acme.com"]["queue"] == "500@acme.com"
    assert captured["acme.com"]["branding"]["company_name"] == "Acme Corp"
    assert "existing.com" in captured  # existing entry preserved


def test_update_domain_overwrites_existing_domain(ud_client):
    existing = {"acme.com": {"api_domain": "acme.com", "user": "OLD"}}
    captured = {}

    def fake_save(data):
        captured.update(data)

    with patch("app.load_domains", return_value=existing), \
         patch("app.save_domains", side_effect=fake_save):
        _post(ud_client)

    assert captured["acme.com"]["user"] == "2000"  # overwritten
```

Add `from unittest.mock import patch` at the top of the test file (alongside the existing imports).

**Step 2: Run test to verify it fails**

```bash
cd Netsapiens-Scripts/webresponder && venv/bin/pytest tests/test_update_domain.py -v -k happy_path or test_update_domain_upserts or test_update_domain_overwrites
```
Expected: FAIL with 404 (route not found)

**Step 3: Write minimal implementation**

Add to `app.py` (before `if __name__ == "__main__":`):

```python
_UPDATE_REQUIRED_FIELDS = [
    "domain", "api_domain", "user", "from_number", "message",
    "queue", "button_text", "button_color", "company_name",
]


@app.route("/update-domain", methods=["POST"])
def update_domain():
    payload = request.form

    # Auth
    if payload.get("update_secret", "") != UPDATE_SECRET:
        log.warning("update-domain: invalid or missing update_secret")
        return {"status": "error", "message": "Forbidden."}, 403

    # Validate required fields
    missing = [f for f in _UPDATE_REQUIRED_FIELDS if not payload.get(f, "").strip()]
    if missing:
        log.warning("update-domain: missing fields: %s", missing)
        return {"status": "error", "message": f"Missing fields: {', '.join(missing)}"}, 400

    domain = payload["domain"].strip()
    if not _DOMAIN_RE.match(domain):
        log.warning("update-domain: invalid domain format: %s", domain)
        return {"status": "error", "message": "Invalid domain format."}, 400

    try:
        domains = load_domains()
    except Exception as exc:
        log.error("update-domain: failed to load domains.json: %s", exc)
        return {"status": "error", "message": "Service temporarily unavailable."}, 500

    domains[domain] = {
        "api_domain": payload["api_domain"].strip(),
        "user": payload["user"].strip(),
        "from_number": payload["from_number"].strip(),
        "message": payload["message"].strip(),
        "queue": payload["queue"].strip(),
        "branding": {
            "button_text": payload["button_text"].strip(),
            "button_color": payload["button_color"].strip(),
            "company_name": payload["company_name"].strip(),
            "logo_url": payload.get("logo_url", "").strip(),
        },
    }

    try:
        save_domains(domains)
        log.info("update-domain: upserted domain=%s", domain)
        return {"status": "ok", "domain": domain}, 200
    except Exception as exc:
        log.error("update-domain: failed to save domains.json: %s", exc)
        return {"status": "error", "message": "Failed to save config."}, 500
```

**Step 4: Run test to verify it passes**

```bash
cd Netsapiens-Scripts/webresponder && venv/bin/pytest tests/test_update_domain.py -v -k "happy_path or upserts or overwrites"
```
Expected: PASS (3 passed)

**Step 5: Commit**

```bash
git add Netsapiens-Scripts/webresponder/app.py \
        Netsapiens-Scripts/webresponder/tests/test_update_domain.py
git commit -m "feat(update-domain): add POST /update-domain route happy path"
```

---

### Task 4: `POST /update-domain` — error path tests

**Files:**
- Test: `Netsapiens-Scripts/webresponder/tests/test_update_domain.py`

**Step 1: Write the failing tests**

Append to `tests/test_update_domain.py`:

```python
def test_update_domain_wrong_secret_returns_403(ud_client):
    payload = {**VALID_PAYLOAD, "update_secret": "wrong"}
    resp = _post(ud_client, payload)
    assert resp.status_code == 403
    assert resp.get_json()["status"] == "error"


def test_update_domain_missing_secret_returns_403(ud_client):
    payload = {k: v for k, v in VALID_PAYLOAD.items() if k != "update_secret"}
    resp = _post(ud_client, payload)
    assert resp.status_code == 403


def test_update_domain_missing_required_field_returns_400(ud_client):
    payload = {**VALID_PAYLOAD}
    del payload["queue"]
    resp = _post(ud_client, payload)
    assert resp.status_code == 400
    assert "queue" in resp.get_json()["message"]


def test_update_domain_invalid_domain_format_returns_400(ud_client):
    payload = {**VALID_PAYLOAD, "domain": "not a domain!!"}
    resp = _post(ud_client, payload)
    assert resp.status_code == 400


def test_update_domain_save_failure_returns_500(ud_client):
    with patch("app.load_domains", return_value={}), \
         patch("app.save_domains", side_effect=OSError("disk full")):
        resp = _post(ud_client)
    assert resp.status_code == 500
    assert resp.get_json()["status"] == "error"


def test_update_domain_logo_url_is_optional(ud_client):
    payload = {k: v for k, v in VALID_PAYLOAD.items() if k != "logo_url"}
    with patch("app.load_domains", return_value={}), \
         patch("app.save_domains") as mock_save:
        resp = _post(ud_client, payload)
    assert resp.status_code == 200
    saved = mock_save.call_args[0][0]
    assert saved["acme.com"]["branding"]["logo_url"] == ""
```

**Step 2: Run tests — should all pass already**

```bash
cd Netsapiens-Scripts/webresponder && venv/bin/pytest tests/test_update_domain.py -v
```
Expected: all PASS. If any fail, fix the route in Task 3's implementation to match.

**Step 3: Commit**

```bash
git add Netsapiens-Scripts/webresponder/tests/test_update_domain.py
git commit -m "test(update-domain): cover error branches for POST /update-domain"
```

---

### Task 5: Full suite run + push

**Step 1: Run all tests**

```bash
cd Netsapiens-Scripts/webresponder && venv/bin/pytest -v
```
Expected: all tests PASS (existing webresponder + click-to-call + update-domain tests)

**Step 2: Push**

```bash
git push
```

**Step 3: Deploy to server**

```bash
# On the server:
cd /opt/ns-scripts && git pull

# Add UPDATE_SECRET to .env (generate a value first):
python3 -c "import secrets; print('UPDATE_SECRET=' + secrets.token_hex(32))" >> \
  /opt/ns-scripts/ClaudProjects/Netsapiens-Scripts/webresponder/.env

systemctl restart ns-webresponder
```

**Step 4: Smoke test the endpoint**

```bash
# Wrong secret → 403
curl -s -X POST http://127.0.0.1:5100/update-domain \
  -d "update_secret=wrong&domain=test.com" | python3 -m json.tool

# Valid submission → 200
curl -s -X POST http://127.0.0.1:5100/update-domain \
  -d "update_secret=<YOUR_SECRET>" \
  -d "domain=test.com" \
  -d "api_domain=test.com" \
  -d "user=2000" \
  -d "from_number=18135550001" \
  -d "message=Test message" \
  -d "queue=500@test.com" \
  -d "button_text=Call Us" \
  -d "button_color=%231a73e8" \
  -d "company_name=Test+Co" | python3 -m json.tool

# Verify it's in domains.json
cat /opt/ns-scripts/ClaudProjects/Netsapiens-Scripts/webresponder/domains.json
```

---

## Zoho Forms setup (after code is deployed)

1. Create a new form in Zoho Forms with the fields from the design doc
2. Add a **Hidden Field** named `update_secret` with the default value set to your `UPDATE_SECRET` value
3. Under **Integrations → Webhooks**, add:
   - URL: `https://ns-scripts.pressone.net/update-domain`
   - Method: POST
   - Map each form field to its parameter name (e.g. `domain` → `domain`)
4. Test with a real submission and verify the domain appears in `domains.json` on the server
