# Click-to-Call Widget Implementation Plan

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

**Goal:** Add a `/click-to-call` API and a branded `/widget.js` embed to the existing webresponder Flask app so website visitors can request a callback that's bridged into a domain-specific NS queue.

**Architecture:** Two new routes in `Netsapiens-Scripts/webresponder/app.py`: `POST /click-to-call` (validates phone number, rate-limits per-IP and per-domain, calls the NS legacy `queuedcall` API with the existing `NS_TOKEN` bearer auth) and `GET /widget.js` (serves a per-domain templated vanilla-JS embed snippet using new `queue`/`branding` fields in `domains.json`).

**Tech Stack:** Python 3, Flask, `requests`, `pytest` + `unittest.mock` (mirrors the existing webresponder app and its test suite).

---

## Reference: existing conventions to follow

- `app.py` re-reads `domains.json` on every request via `load_domains()` — reuse it.
- HTTP calls to NS go through small wrapper functions like `send_sms()` that raise on HTTP errors (`resp.raise_for_status()`); the route catches `requests.HTTPError`/`Exception` and logs.
- Logging: `log = logging.getLogger(__name__)`, structured `log.info("event: key=%s key2=%s", ...)`.
- Tests live in `tests/test_app.py`, use a `client` fixture (`app.test_client()`), patch `app.load_domains` and the NS-call wrapper with `unittest.mock.patch`, and define a local `FAKE_DOMAINS` dict per test module (or extend the shared one).
- `tests/conftest.py` sets `NS_TOKEN` env var before `app` is imported.

---

### Task 1: Add `queue` and `branding` fields to `domains.json`

**Files:**
- Modify: `Netsapiens-Scripts/webresponder/domains.json`

**Step 1: Add the new fields to the existing `pressone.net` entry**

Edit the file to look like:

```json
{
  "pressone.net": {
    "api_domain": "pressone.net",
    "user": "2000",
    "from_number": "19294873999",
    "message": "Our hours are Mon-Fri 9am-5pm. We are located at 123 Main St, Tampa FL 33601.",
    "queue": "500",
    "branding": {
      "button_text": "Call Us Now",
      "button_color": "#1a73e8",
      "company_name": "PressOne",
      "logo_url": ""
    }
  }
}
```

**Step 2: Commit**

```bash
cd Netsapiens-Scripts/webresponder
git add domains.json
git commit -m "feat(click-to-call): add queue and branding config fields"
```

---

### Task 2: Phone number validation helper — write failing test

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

**Step 1: Write the failing test**

Create `tests/test_click_to_call.py`:

```python
from app import normalize_phone_number


def test_normalize_accepts_ten_digit_us_number():
    assert normalize_phone_number("8135551234") == "+18135551234"


def test_normalize_accepts_e164_with_plus():
    assert normalize_phone_number("+18135551234") == "+18135551234"


def test_normalize_accepts_eleven_digit_with_leading_one():
    assert normalize_phone_number("18135551234") == "+18135551234"


def test_normalize_strips_formatting_characters():
    assert normalize_phone_number("(813) 555-1234") == "+18135551234"


def test_normalize_rejects_too_short_number():
    assert normalize_phone_number("555-1234") is None


def test_normalize_rejects_non_numeric_garbage():
    assert normalize_phone_number("not a phone number") is None


def test_normalize_rejects_empty_string():
    assert normalize_phone_number("") is None
```

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

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

**Step 3: Write minimal implementation**

In `app.py`, add near the top (after `_DOMAIN_RE`):

```python
_PHONE_RE = re.compile(r'^\+?1?(\d{10})$')


def normalize_phone_number(raw):
    """Return E.164 (+1XXXXXXXXXX) for valid US numbers, else None."""
    digits_and_plus = re.sub(r'[^\d+]', '', raw or "")
    match = _PHONE_RE.match(digits_and_plus)
    if not match:
        return None
    return f"+1{match.group(1)}"
```

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

Run: `cd Netsapiens-Scripts/webresponder && venv/bin/pytest tests/test_click_to_call.py -v`
Expected: PASS (7 passed)

**Step 5: Commit**

```bash
cd Netsapiens-Scripts/webresponder
git add app.py tests/test_click_to_call.py
git commit -m "feat(click-to-call): add phone number normalization/validation"
```

---

### Task 3: Rate limiter helper — write failing test

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

**Step 1: Write the failing test**

Append to `tests/test_click_to_call.py`:

```python
import time
from app import RateLimiter


def test_rate_limiter_allows_up_to_limit():
    rl = RateLimiter(limit=3, window_seconds=60)
    assert rl.allow("ip:1.2.3.4") is True
    assert rl.allow("ip:1.2.3.4") is True
    assert rl.allow("ip:1.2.3.4") is True


def test_rate_limiter_blocks_over_limit():
    rl = RateLimiter(limit=3, window_seconds=60)
    for _ in range(3):
        rl.allow("ip:1.2.3.4")
    assert rl.allow("ip:1.2.3.4") is False


def test_rate_limiter_tracks_keys_independently():
    rl = RateLimiter(limit=1, window_seconds=60)
    assert rl.allow("ip:1.1.1.1") is True
    assert rl.allow("ip:2.2.2.2") is True
    assert rl.allow("ip:1.1.1.1") is False


def test_rate_limiter_resets_after_window_expires():
    rl = RateLimiter(limit=1, window_seconds=0.05)
    assert rl.allow("ip:1.2.3.4") is True
    assert rl.allow("ip:1.2.3.4") is False
    time.sleep(0.06)
    assert rl.allow("ip:1.2.3.4") is True
```

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

Run: `cd Netsapiens-Scripts/webresponder && venv/bin/pytest tests/test_click_to_call.py -v -k RateLimiter`
Expected: FAIL with `ImportError: cannot import name 'RateLimiter'`

**Step 3: Write minimal implementation**

In `app.py`, add below `normalize_phone_number`:

```python
import threading
import time as time_module


class RateLimiter:
    """Simple in-process sliding-window rate limiter keyed by string."""

    def __init__(self, limit, window_seconds):
        self.limit = limit
        self.window_seconds = window_seconds
        self._hits = {}
        self._lock = threading.Lock()

    def allow(self, key):
        now = time_module.monotonic()
        cutoff = now - self.window_seconds
        with self._lock:
            hits = [t for t in self._hits.get(key, []) if t > cutoff]
            if len(hits) >= self.limit:
                self._hits[key] = hits
                return False
            hits.append(now)
            self._hits[key] = hits
            return True
```

Note: `import threading` and `import time as time_module` should go in the existing top-of-file import block, not inline — adjust placement accordingly when editing.

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

Run: `cd Netsapiens-Scripts/webresponder && venv/bin/pytest tests/test_click_to_call.py -v -k RateLimiter`
Expected: PASS (4 passed)

**Step 5: Commit**

```bash
cd Netsapiens-Scripts/webresponder
git add app.py tests/test_click_to_call.py
git commit -m "feat(click-to-call): add in-process sliding-window rate limiter"
```

---

### Task 4: `originate_queued_call()` NS API wrapper — write failing test

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

**Step 1: Write the failing test**

Append to `tests/test_click_to_call.py`:

```python
from unittest.mock import patch, MagicMock
from app import originate_queued_call


def test_originate_queued_call_posts_expected_params():
    with patch("app.requests.post") as mock_post:
        mock_post.return_value = MagicMock(status_code=202)
        mock_post.return_value.raise_for_status = MagicMock()
        originate_queued_call(queue="500", destination="+18135551234")

    mock_post.assert_called_once()
    args, kwargs = mock_post.call_args
    assert kwargs["data"] == {
        "format": "json",
        "uid": "500",
        "queue": "500",
        "destination": "+18135551234",
    }
    assert kwargs["headers"]["Authorization"].startswith("Bearer ")


def test_originate_queued_call_raises_on_http_error():
    with patch("app.requests.post") as mock_post:
        mock_post.return_value = MagicMock(status_code=400)
        mock_post.return_value.raise_for_status.side_effect = req_lib.HTTPError(
            response=mock_post.return_value
        )
        try:
            originate_queued_call(queue="500", destination="+18135551234")
            assert False, "expected HTTPError"
        except req_lib.HTTPError:
            pass
```

Add `import requests as req_lib` to the top of the test file alongside the existing imports if not already present (it's needed in `test_app.py`'s style — add it to this new file too).

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

Run: `cd Netsapiens-Scripts/webresponder && venv/bin/pytest tests/test_click_to_call.py -v -k originate`
Expected: FAIL with `ImportError: cannot import name 'originate_queued_call'`

**Step 3: Write minimal implementation**

In `app.py`, add a constant near `NS_API_BASE` and the function near `send_sms`:

```python
NS_LEGACY_API_BASE = "https://ucportal.pressone.net/ns-api/"


def originate_queued_call(queue, destination):
    """POST a queuedcall create request to the NS legacy API.

    API spec (from api_doc_collection.json):
      POST /?object=queuedcall&action=create
      Body: multipart form-data
        format=json  (required)
        uid=<queue>  (required)
        queue=<queue> (required)
        destination=<E.164 phone number> (required)
      Success: 202 text/plain empty body
    """
    resp = requests.post(
        NS_LEGACY_API_BASE,
        params={"object": "queuedcall", "action": "create"},
        data={
            "format": "json",
            "uid": queue,
            "queue": queue,
            "destination": destination,
        },
        headers={"Authorization": f"Bearer {NS_TOKEN}"},
        timeout=10,
    )
    resp.raise_for_status()
    return resp
```

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

Run: `cd Netsapiens-Scripts/webresponder && venv/bin/pytest tests/test_click_to_call.py -v -k originate`
Expected: PASS (2 passed)

**Step 5: Commit**

```bash
cd Netsapiens-Scripts/webresponder
git add app.py tests/test_click_to_call.py
git commit -m "feat(click-to-call): add queuedcall API wrapper for outbound call origination"
```

---

### Task 5: `POST /click-to-call` route — happy path test

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

**Step 1: Write the failing test**

Append to `tests/test_click_to_call.py`:

```python
import pytest
import json as json_module

CTC_FAKE_DOMAINS = {
    "testco.net": {
        "api_domain": "testco.net",
        "queue": "500",
        "branding": {
            "button_text": "Call Us",
            "button_color": "#000000",
            "company_name": "TestCo",
            "logo_url": "",
        },
    }
}


@pytest.fixture
def ctc_client():
    app.testing = True
    # fresh limiter state per test so they don't interfere with each other
    import app as app_module
    app_module._ip_limiter = RateLimiter(limit=3, window_seconds=60)
    app_module._domain_limiter = RateLimiter(limit=30, window_seconds=3600)
    return app.test_client()


def _ctc_post(client, **overrides):
    body = {"domain": "testco.net", "phone_number": "8135551234", **overrides}
    return client.post(
        "/click-to-call",
        data=json_module.dumps(body),
        content_type="application/json",
    )


def test_click_to_call_happy_path_returns_200(ctc_client):
    with patch("app.load_domains", return_value=CTC_FAKE_DOMAINS), \
         patch("app.originate_queued_call") as mock_call:
        mock_call.return_value = MagicMock()
        resp = _ctc_post(ctc_client)
    assert resp.status_code == 200
    assert resp.get_json() == {"status": "ok"}


def test_click_to_call_happy_path_calls_originate_with_normalized_number(ctc_client):
    with patch("app.load_domains", return_value=CTC_FAKE_DOMAINS), \
         patch("app.originate_queued_call") as mock_call:
        mock_call.return_value = MagicMock()
        _ctc_post(ctc_client)
    mock_call.assert_called_once_with(queue="500", destination="+18135551234")
```

You'll need `from app import app, RateLimiter` (extend the existing `from app import ...` line) at the top of the test file.

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

Run: `cd Netsapiens-Scripts/webresponder && venv/bin/pytest tests/test_click_to_call.py -v -k happy_path`
Expected: FAIL with 404 (`/click-to-call` route doesn't exist) — `assert 404 == 200`

**Step 3: Write minimal implementation**

In `app.py`, add module-level limiter instances (near `DOMAINS_FILE`):

```python
_ip_limiter = RateLimiter(limit=3, window_seconds=60)
_domain_limiter = RateLimiter(limit=30, window_seconds=3600)
```

Add the route (near the bottom, before `if __name__ == "__main__":`):

```python
@app.route("/click-to-call", methods=["POST"])
def click_to_call():
    payload = request.get_json(silent=True) or {}
    domain = (payload.get("domain") or "").strip()
    raw_phone = (payload.get("phone_number") or "").strip()
    client_ip = request.headers.get("X-Forwarded-For", request.remote_addr or "")

    log.info("click-to-call: domain=%s phone=%s ip=%s", domain, raw_phone, client_ip)

    if not domain or not _DOMAIN_RE.match(domain):
        return _ctc_error("Unknown domain.", 404)

    try:
        domains = load_domains()
    except Exception as exc:
        log.error("failed to load domains.json: %s", exc)
        return _ctc_error("Service temporarily unavailable.", 500)

    config = domains.get(domain)
    if not config or not config.get("queue"):
        log.warning("click-to-call: domain not configured for queue: %s", domain)
        return _ctc_error("Unknown domain.", 404)

    phone = normalize_phone_number(raw_phone)
    if not phone:
        return _ctc_error("Please enter a valid US phone number.", 400)

    if not _ip_limiter.allow(f"ip:{client_ip}"):
        log.warning("click-to-call: rate limit exceeded for ip=%s", client_ip)
        return _ctc_error("Too many requests. Please try again in a minute.", 429)

    if not _domain_limiter.allow(f"domain:{domain}"):
        log.warning("click-to-call: rate limit exceeded for domain=%s", domain)
        return _ctc_error("Too many requests. Please try again later.", 429)

    try:
        originate_queued_call(queue=config["queue"], destination=phone)
        log.info("click-to-call: originated call to %s for domain=%s", phone, domain)
        return {"status": "ok"}, 200
    except requests.HTTPError as exc:
        log.error("queuedcall API HTTP error: %s %s", exc.response.status_code, exc.response.text)
        return _ctc_error("Could not place your call right now. Please try again.", 502)
    except Exception as exc:
        log.error("queuedcall API request failed: %s", exc)
        return _ctc_error("Could not place your call right now. Please try again.", 502)


def _ctc_error(message, status_code):
    return {"status": "error", "message": message}, status_code
```

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

Run: `cd Netsapiens-Scripts/webresponder && venv/bin/pytest tests/test_click_to_call.py -v -k happy_path`
Expected: PASS (2 passed)

**Step 5: Commit**

```bash
cd Netsapiens-Scripts/webresponder
git add app.py tests/test_click_to_call.py
git commit -m "feat(click-to-call): add POST /click-to-call route happy path"
```

---

### Task 6: `POST /click-to-call` — error-path tests

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

**Step 1: Write the failing tests**

Append to `tests/test_click_to_call.py`:

```python
def test_click_to_call_unknown_domain_returns_404(ctc_client):
    with patch("app.load_domains", return_value=CTC_FAKE_DOMAINS):
        resp = _ctc_post(ctc_client, domain="unknown.net")
    assert resp.status_code == 404
    assert resp.get_json()["status"] == "error"


def test_click_to_call_invalid_phone_returns_400(ctc_client):
    with patch("app.load_domains", return_value=CTC_FAKE_DOMAINS):
        resp = _ctc_post(ctc_client, phone_number="abc")
    assert resp.status_code == 400
    assert resp.get_json()["status"] == "error"


def test_click_to_call_ip_rate_limit_returns_429(ctc_client):
    with patch("app.load_domains", return_value=CTC_FAKE_DOMAINS), \
         patch("app.originate_queued_call") as mock_call:
        mock_call.return_value = MagicMock()
        for _ in range(3):
            assert _ctc_post(ctc_client).status_code == 200
        resp = _ctc_post(ctc_client)
    assert resp.status_code == 429
    assert resp.get_json()["status"] == "error"


def test_click_to_call_queuedcall_http_error_returns_502(ctc_client):
    mock_response = MagicMock(status_code=400, text="Bad Request")
    http_err = req_lib.HTTPError(response=mock_response)
    with patch("app.load_domains", return_value=CTC_FAKE_DOMAINS), \
         patch("app.originate_queued_call", side_effect=http_err):
        resp = _ctc_post(ctc_client)
    assert resp.status_code == 502
    assert resp.get_json()["status"] == "error"


def test_click_to_call_domain_missing_queue_returns_404(ctc_client):
    domains_without_queue = {"testco.net": {"api_domain": "testco.net", "branding": {}}}
    with patch("app.load_domains", return_value=domains_without_queue):
        resp = _ctc_post(ctc_client)
    assert resp.status_code == 404
```

**Step 2: Run tests to verify current status**

Run: `cd Netsapiens-Scripts/webresponder && venv/bin/pytest tests/test_click_to_call.py -v -k click_to_call`
Expected: All PASS already (Task 5's implementation already covers these branches) — this task is pure regression coverage. If any fail, fix the route logic in `app.py` from Task 5 to match, then re-run.

**Step 3: Commit**

```bash
cd Netsapiens-Scripts/webresponder
git add tests/test_click_to_call.py
git commit -m "test(click-to-call): cover error branches for POST /click-to-call"
```

---

### Task 7: `GET /widget.js` route — write failing test

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

**Step 1: Write the failing test**

Append to `tests/test_click_to_call.py`:

```python
def test_widget_js_returns_javascript_content_type(ctc_client):
    with patch("app.load_domains", return_value=CTC_FAKE_DOMAINS):
        resp = ctc_client.get("/widget.js?domain=testco.net")
    assert resp.status_code == 200
    assert "javascript" in resp.content_type


def test_widget_js_includes_branding_values(ctc_client):
    with patch("app.load_domains", return_value=CTC_FAKE_DOMAINS):
        resp = ctc_client.get("/widget.js?domain=testco.net")
    body = resp.get_data(as_text=True)
    assert "Call Us" in body
    assert "#000000" in body
    assert "TestCo" in body
    assert "/click-to-call" in body


def test_widget_js_unknown_domain_returns_404(ctc_client):
    with patch("app.load_domains", return_value=CTC_FAKE_DOMAINS):
        resp = ctc_client.get("/widget.js?domain=unknown.net")
    assert resp.status_code == 404
```

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

Run: `cd Netsapiens-Scripts/webresponder && venv/bin/pytest tests/test_click_to_call.py -v -k widget_js`
Expected: FAIL with 404 for the content-type/branding tests (route missing) — `assert 404 == 200`

**Step 3: Write minimal implementation**

In `app.py`, add a template string near the other `_XML_*` constants:

```python
_WIDGET_JS_TEMPLATE = """
(function () {
  var BUTTON_TEXT = %(button_text)s;
  var BUTTON_COLOR = %(button_color)s;
  var COMPANY_NAME = %(company_name)s;
  var ENDPOINT = %(endpoint)s;
  var DOMAIN = %(domain)s;

  var button = document.createElement("button");
  button.textContent = BUTTON_TEXT;
  button.style.backgroundColor = BUTTON_COLOR;
  button.style.color = "#fff";
  button.style.border = "none";
  button.style.borderRadius = "6px";
  button.style.padding = "10px 16px";
  button.style.cursor = "pointer";

  var form = document.createElement("div");
  form.style.display = "none";

  var input = document.createElement("input");
  input.type = "tel";
  input.placeholder = "Your phone number";

  var submit = document.createElement("button");
  submit.textContent = "Call me";

  var status = document.createElement("span");

  form.appendChild(input);
  form.appendChild(submit);
  form.appendChild(status);

  button.addEventListener("click", function () {
    form.style.display = form.style.display === "none" ? "block" : "none";
  });

  submit.addEventListener("click", function () {
    status.textContent = "";
    submit.disabled = true;
    fetch(ENDPOINT, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ domain: DOMAIN, phone_number: input.value })
    })
      .then(function (resp) { return resp.json().then(function (data) { return { ok: resp.ok, data: data }; }); })
      .then(function (result) {
        submit.disabled = false;
        status.textContent = result.ok
          ? "We're calling you now!"
          : (result.data && result.data.message) || "Something went wrong.";
      })
      .catch(function () {
        submit.disabled = false;
        status.textContent = "Something went wrong. Please try again.";
      });
  });

  var container = document.currentScript.parentNode;
  container.appendChild(button);
  container.appendChild(form);
})();
"""


def _render_widget_js(domain, config):
    branding = config.get("branding") or {}
    values = {
        "button_text": json.dumps(branding.get("button_text", "Call Us Now")),
        "button_color": json.dumps(branding.get("button_color", "#1a73e8")),
        "company_name": json.dumps(branding.get("company_name", "")),
        "endpoint": json.dumps("/click-to-call"),
        "domain": json.dumps(domain),
    }
    return _WIDGET_JS_TEMPLATE % values
```

Add the route:

```python
@app.route("/widget.js", methods=["GET"])
def widget_js():
    domain = (request.args.get("domain") or "").strip()

    if not domain or not _DOMAIN_RE.match(domain):
        return Response("// unknown domain", status=404, content_type="application/javascript")

    try:
        domains = load_domains()
    except Exception as exc:
        log.error("failed to load domains.json: %s", exc)
        return Response("// service unavailable", status=500, content_type="application/javascript")

    config = domains.get(domain)
    if not config:
        return Response("// unknown domain", status=404, content_type="application/javascript")

    return Response(_render_widget_js(domain, config), content_type="application/javascript")
```

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

Run: `cd Netsapiens-Scripts/webresponder && venv/bin/pytest tests/test_click_to_call.py -v -k widget_js`
Expected: PASS (3 passed)

**Step 5: Commit**

```bash
cd Netsapiens-Scripts/webresponder
git add app.py tests/test_click_to_call.py
git commit -m "feat(click-to-call): add GET /widget.js branded embed snippet"
```

---

### Task 8: Full suite run + push

**Files:** none (verification task)

**Step 1: Run the entire test suite**

Run: `cd Netsapiens-Scripts/webresponder && venv/bin/pytest -v`
Expected: All tests pass (existing webresponder tests + new click-to-call tests)

**Step 2: Push**

Per [[feedback_server_git_discipline]] (always commit and push promptly):

```bash
cd Netsapiens-Scripts/webresponder
git push
```

**Step 3: Update the design doc status (optional housekeeping)**

If the design doc at `Netsapiens-Scripts/docs/plans/2026-06-08-click-to-call-widget-design.md` has a status field/note, mark it implemented. Otherwise skip — the doc can stand as written.

---

## Notes for the implementer

- The `queuedcall` API spec was confirmed from `api_doc_collection.json`: **POST** to `/?object=queuedcall&action=create`, body is **multipart form-data** with fields `format=json`, `uid`, `queue`, `destination`. Success is **202** text/plain with an empty body. The implementation above matches this exactly.
- Deploy is just a restart of the existing `ns-webresponder.service` (no new systemd unit) once changes are pushed and pulled on the server, per [[feedback_server_git_discipline]].
