# CID Router — NetSapiens web responder

Routes inbound calls to different destinations based on the caller's number.
Multi-domain: each customer domain has its own lists, each list has its own
destination, and the domain has a default for everyone else.

```
main number ─► TOD ─► (office hours) routing user ─► to-webresponder ─► router.php
                                                          │
                                       match list A ──────┼──► list A destination (e.g. provider queue)
                                       match list B ──────┼──► list B destination
                                       no match ──────────┼──► domain default (AA)
                                       error / timeout ───┴──► next dial rule (AA)
```

## Files

| File | Purpose |
|---|---|
| `router.php` | The web responder NetSapiens calls |
| `admin.php` | Web admin for support staff: same operations in a browser |
| `routectl.php` | CLI: set up domains, lists, import numbers, test, view log |
| `lib/routing.php` | Shared config/DB/normalization/lookup |
| `sql/schema.sql` | Tables (MySQL or SQLite) |
| `sql/mysql-setup.sql` | One-time MySQL database and user creation |
| `config.ini.example` | Copy to `../config.ini` |

## Install

Requires PHP 8.1+ with `pdo_mysql` (or `pdo_sqlite`).

```bash
# 1. database and user (edit the password in the file first)
mysql -u root -p < sql/mysql-setup.sql

# 2. config
cp config.ini.example ../config.ini      # set DB_PASS, ALLOWED_IPS, ADMIN_PASSWORD_HASH

# 3. tables
php routectl.php init                    # safe to re-run; also applies column migrations
```

`init` needs CREATE/ALTER on the database; the setup script grants those and
shows how to revoke them afterwards if you want the runtime user limited to DML.

SQLite still works if you'd rather not touch MySQL: point `DB_DSN` at a file
the web server user can write, in a directory it can also write (WAL mode
creates `-wal`/`-shm` siblings).

## Web admin

`admin.php` covers everything the CLI does, for staff who won't use a shell:
add customers and lists, add/edit/remove numbers with labels and notes, search,
CSV import/export, a caller test, the call log, and enable/disable switches.

```bash
php routectl.php admin hash-password      # paste the output line into ../config.ini
```

Then open `https://<responder-host>/cid-router/admin.php`. Restrict
`ADMIN_ALLOWED_IPS` to your office/VPN range, and serve it over HTTPS — the
session cookie is marked secure when the request is HTTPS.

## Set up a customer

```bash
# domain + where non-matching callers go
php routectl.php domain add customer.example.com 8000 "Customer name"

# one or more lists, each with its own destination; lower priority wins on overlap
php routectl.php list add customer.example.com providers 8001 10
php routectl.php list add customer.example.com collections 3050 20

# load numbers (one per line; optional ,label,note columns; header row ok)
php routectl.php number import customer.example.com providers providers.csv
php routectl.php number import customer.example.com providers providers.csv --replace   # full resync

# verify without a phone
php routectl.php test customer.example.com "+1 (615) 555-0100"
php routectl.php list show customer.example.com
```

Destinations are whatever `<Dial>` accepts on your platform — an extension,
queue, or user. If you need `user@domain`, set `DIAL_TEMPLATE = "{destination}@{domain}"`
in config rather than storing it per row.

## NetSapiens side

On the office-hours routing user, in dial translation order:

1. `to-webresponder` with parameter
   `https://<responder-host>/cid-router/router.php?domain=customer.example.com`
   Set the responder timeout short (2–3 s).
2. The auto attendant — this is the fall-through when the responder is down
   or returns 500.

The `domain` is fixed in the URL per routing user (same pattern as `?agent=101`
elsewhere). NetSapiens appends the caller/called/call-ID parameters itself.

**First deployment:** make one test call and check the web server error log.
If you see `no caller param found; query=...`, the parameter names NetSapiens
sends differ from the defaults — add the real name to `PARAM_FROM` in config.
The full query string is logged so you don't have to guess.

## Matching rules

- Caller ID is normalized before lookup: `+1 615 555 0100`, `16155550100`, and
  `6155550100` all match the same row. Non-NANP numbers are stored as full digits.
- Anonymous / restricted / empty caller ID → domain default.
- A number in multiple lists routes to the list with the lowest `priority`.
- Disabled lists are ignored; a disabled domain fails through to the next dial rule.

## Operations

```bash
php routectl.php log customer.example.com 100          # last 100 decisions
php routectl.php number find customer.example.com 6155550100
php routectl.php number add    customer.example.com providers 6155550199 "New provider" "Added per ticket 4412, requested by office mgr"
php routectl.php number note   customer.example.com providers 6155550199 "Moved to collections list 2026-10-01"
php routectl.php number remove customer.example.com providers 6155550199
php routectl.php list set customer.example.com providers --destination=8002
php routectl.php list disable customer.example.com providers   # temporarily send everyone to default
php routectl.php domain disable customer.example.com           # bypass the responder entirely
```

`routing_log` grows one row per call; prune it on a cron or turn `LOG_CALLS` off:

```sql
DELETE FROM routing_log WHERE ts < DATE_FORMAT(NOW() - INTERVAL 90 DAY, '%Y-%m-%dT%H:%i:%sZ');  -- MySQL
DELETE FROM routing_log WHERE ts < strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-90 days');         -- SQLite
```

## Go-live checklist

- [ ] `ALLOWED_IPS` restricted to the core/SiPbx addresses
- [ ] Test call from a listed number → lands in the list destination
- [ ] Test call from an unlisted number → lands in the AA
- [ ] Stop the web server, test call → still lands in the AA via dial-rule fall-through
- [ ] `routectl.php log` shows the three calls with sane `elapsed_ms` (single digits)
- [ ] After-hours call never touches the responder (TOD branch)
