Commit Graph
52 Commits
Author SHA1 Message Date
iamdoubz 324f9d9fe2 docs: admin guide (install, voip.ms, network, E911, troubleshooting, updating)
ci / check (push) Successful in 56s
ci / images (push) Successful in 23s
docs/admin-guide/: install and the setup wizard, voip.ms with screenshots, NAT and SIP
ALG, firewall (ufw, nftables limited to the voip.ms POP), fail2ban and CrowdSec, E911
duties, recording consent, troubleshooting (08 §3.3), updating, backup and restore,
monitoring and log rotation. scripts/update.sh as 08 §3.1 describes. The e2e checks
fail2ban's and CrowdSec's stock Asterisk rules against a real failed sign-in, takes the
screenshots and checks every link and anchor.

Task: M11-T7
2026-10-20 08:57:54 -05:00
iamdoubz 29ee4ba105 reports: calls per day, missed, busiest hours, queue table
ci / check (push) Successful in 56s
ci / images (push) Successful in 35s
GET /api/reports/summary and /api/reports/queues (company-time-zone days, last 30 by
default, a year at most) and the Reports page: totals, stacked bars per day, missed per
day, a weekday x hour heatmap and the queue table. Plain divs, no chart library.

Task: M11-T6
2026-10-19 22:09:43 -05:00
iamdoubz 60846afddd integrations: webhooks and API tokens
ci / check (push) Successful in 54s
ci / images (push) Successful in 21s
API tokens (Settings -> API tokens): Authorization: Bearer sp_<40 base62>,
stored as sha256, shown once. A token acts as its admin with their current
role and stops when they're disabled; Bearer requests skip the CSRF header
and never fall back to the cookie. A token can't manage tokens.

Webhooks (Settings -> Webhooks): call.started/answered/ended,
voicemail.new, emergency.call, trunk/extension registration changes, from
the live state; queued per webhook, sent every 10 s with
X-SelfPhoneNG-Signature (HMAC of timestamp.body), retried after 1 m, 5 m,
30 m, 2 h, 12 h, then failed. Private/loopback targets are refused in the
connection's own DNS lookup (no rebinding), unless allowed. Send test and
a delivery log per webhook.

The e2e found voicemail.new missing for mailboxes without a user: the
voicemail scan now queues it for every mailbox. vmpin.test.ts gets 180 s:
Git Bash forks make it ~85 s on Windows (the earlier flaky gate run).

Task: M11-T5
2026-10-19 19:40:47 -05:00
iamdoubz d229ed2c45 jobs: public IP, trunk and disk watches, retention
ci / check (push) Successful in 57s
ci / images (push) Successful in 25s
apps/api/src/jobs.ts takes over the once-a-minute timer from index.ts
(no croner: every schedule in 04 §8 is whole minutes). Each job has a
running flag; errors are logged.

- Retention (at start and when the date changes, once AMI is up):
  recordings, voicemail past voicemail.retentionDays (VoicemailRemove, so
  Asterisk renumbers and updates MWI), expired sessions, webhook
  deliveries older than 30 days.
- publicIpWatch (every 5 min, Settings -> Network): a changed IP is saved,
  audited, applied (restart required) and emailed to admins.
- trunkWatch: a registering trunk rejected/unregistered for 5 minutes ->
  one email per outage.
- diskWatch (hourly): under 10% free -> one email a day.
- A failed daily backup emails admins too.

Settings gets a Network tab (public IP, Detect, re-check) and voicemail
retention on the Recording tab.

Task: M11-T4
2026-10-19 18:37:08 -05:00
iamdoubz 164a5f0990 backups: daily backup, Back up now, download, restore.sh
ci / check (push) Successful in 54s
ci / images (push) Successful in 20s
A backup is data/backups/selfphoneng-YYYYMMDD-HHMM.tar.gz (mode 0600):
pg_dump, voicemail, custom sounds, music on hold, recordings if asked,
and a manifest. No generated Asterisk config: it holds plaintext
passwords and comes back from the database. The api makes one a day after
backup.time (once-a-minute check, one try a day) and the dashboard warns
when today's is missing. Settings -> Backups sets the schedule, lists them
for download and has Back up now.

scripts/restore.sh <file> refuses a backup made under another APP_SECRET
(the manifest carries a fingerprint, not the secret), asks for yes, then
replaces the database and the folders the backup has.

Tested on two throwaway servers (docker:dind running the real images):
back up on one, restore on the other. That found the api image crashing
on start since M1: tsc doesn't copy auth/common-passwords.txt into dist.
The Dockerfile copies it now and CI loads the app inside the image.

Task: M11-T3
2026-10-19 17:07:58 -05:00
iamdoubz 803859a1d2 sip: TLS transport with a checked certificate, TLS + SRTP trunks
ci / check (push) Successful in 58s
ci / images (push) Successful in 24s
The certificate lives in data/tls (fullchain.pem + privkey.pem), mounted
read-only into asterisk and the api. Asterisk quietly drops transport-tls
when the files are missing, so the api won't turn TLS on unless they load
and match, shows the certificate on Settings -> Security, and warns on the
dashboard when it expires within 14 days. scripts/tls-cert.sh copies it
from the bundled Caddy (or given paths) and reloads res_pjsip: Asterisk 24
re-reads a changed certificate on reload, calls stay up.

transport-tls checks the provider's certificate (verify_server). Asterisk
24 checks a qualify's certificate against the IP instead of the name, which
fails on voip.ms, so TLS trunks don't qualify; REGISTER and INVITE verify
the name fine.

Task: M11-T2
2026-10-19 16:33:11 -05:00
iamdoubz 8c750c67ca auth: two-step sign-in (TOTP) for admins, with recovery codes
ci / check (push) Successful in 1m1s
ci / images (push) Successful in 27s
auth/totp.ts per 08 §2.4: RFC 6238 (HMAC-SHA1, 30 s, 6 digits, ±1
step) with node:crypto only, checked against the RFC's test vectors.
Migration 0003: users.totp_recovery_hashes and totp_last_step.

Admins turn it on from My account (the header's "Account"): scan the
QR or type the key, confirm a code, get 10 recovery codes once. Login
asks for the code only after the password is right (401 totp_required,
no session); a wrong code is a failed login (rate limit, audit). A
step signs in once (totp_last_step updated atomically) and a recovery
code once (array_remove guarded by any()). Off and new codes need the
password; another admin can turn it off for a lost phone, which signs
that user out. Users list shows it (twoStep).

Also, found by the full e2e chain on one shared database:
- Linphone tokens: only the exact token issued is accepted. The last
  base64 character can carry ignored bits, so another spelling decoded
  to the same signed token.
- Test scripts no longer assume a fresh system: m3-t3 picks Dan in the
  test box, m8-check dials whatever slot the park took (findslot =
  next), m10-t4 uses the admin's existing extension, m11-t1 clears the
  public IP on its own first (local nets are in the transports while
  one is set, and transports change only on a restart).

e2e m11-t1-totp; the full chain M1-M11 (33 scripts) passes.

Task: M11-T1
2026-10-19 15:41:53 -05:00
iamdoubz 65e777ab41 security: headers, phone ACL, PIN rules for PINs set from the phone
Headers (08 §2.4) on every response: CSP (connect-src also allows
WEBRTC_WS_URL's origin when set), nosniff, Referrer-Policy,
Permissions-Policy. The dark-mode bootstrap moved from an inline
<script> to /theme.js so script-src stays 'self'.

Phone ACL (08 §2.2): with sip.restrictPhonesToLocalNets, every desk
extension endpoint gets deny 0.0.0.0/0 and permit for each local and
extra net. The spec's snippet was wrong: contact_acl names acl.conf
sections, a pjsip type=acl is the global ACL (it would filter trunks),
and Contact-header rules let a NATed remote phone through. Browser
endpoints come through the reverse proxy and aren't filtered. Golden
case phone-acl. Settings → Security holds it, with the provisioning
switches.

PIN rules for PINs changed from the phone's voicemail menu, which went
around the api: externpasscheck = sp-vm-pincheck (in the Asterisk
image, same rules as isTrivialPin, a unit test keeps them in step) and
minpassword = 4. Accepted PINs reach the api through AMI
VoicemailPasswordChange and are stored; before, the next config write
silently put the old PIN back.

e2e m11-t1: no CSP violations across admin pages; Asterisk refuses a
REGISTER ("Not match Endpoint ACL") until the network is listed; from
the phone, 1111 is refused by the check script and 7391 is stored and
survives a later config write.

Task: M11-T1
2026-10-19 14:47:04 -05:00
iamdoubz 62939f0138 live: listen, whisper and barge on calls in progress
ci / check (push) Successful in 54s
ci / images (push) Successful in 23s
POST /api/live/calls/:linkedid/spy {mode} per 04 §7.3: rings the
admin's desk phone (409 if it isn't connected, 400 without an
extension) and, when they answer, runs ChanSpy on the call's first
user leg (never a trunk or Local channel, never the admin's own),
which for an inbound or queue call is the agent. Options qE / qEw /
qEB: no beep or name, the spy leg ends with the call, whisper to that
user only, or barge (both sides hear). Audited as call_spy.

Dashboard: Listen / Whisper / Barge on talking calls with a user on
them, and "your desk phone is ringing" once sent. The spy leg shows as
its own call; its Hang up stops listening.

e2e m10-t4: 380 calls 381, the admin's 382 runs ChanSpy with each
mode's options, and the barge leg ends with the call. The roadmap
records that M10's milestone check (real Yealink/Grandstream, Linphone
scanning the QR) waits for hardware the owner doesn't have yet.

Task: M10-T4
2026-10-19 14:30:48 -05:00
iamdoubz c1f8b04e7e provisioning: Linphone setup QR, "Set up a phone app"
ci / check (push) Successful in 53s
ci / images (push) Successful in 24s
GET /api/extensions/:id/softphone-qr (admin) and /api/me/softphone-qr
return {url, qr, expiresAt}: the link Linphone fetches and its QR as a
PNG data URL (qrcode), valid 24 h. Both are audited like revealing the
SIP password. JSON rather than a bare PNG, so the page can show the
link for desktop Linphone too.

The token is stateless per 07 §5: base64url(extensionId.expiry.hmac),
HMAC-SHA256 with APP_SECRET, checked timing-safe. /prov/linphone/
<token>.xml matches it before the other file names are lower-cased
(the token is case-sensitive); a bad or expired token is a 404 and a
counted failure. auth_info keys checked against liblinphone's config
reader; proxy keys stay (verify).

Web: a "Set up a phone app" section on My settings and in the admin
Credentials tab: Linphone with the QR, MicroSIP/Baresip by hand, and
the plain warning about mobile apps without push. e2e m10-t3: as a
user, the QR's link gives the XML and a baresip phone registers with
only what it holds.

Task: M10-T3
2026-10-19 14:16:28 -05:00
iamdoubz 6195aca2fb provisioning: BLF keys, time zone table, company phonebook
ci / check (push) Successful in 56s
ci / images (push) Successful in 26s
Yealink: BLF keys from line key 2, named after the extension (or the
number, e.g. a parking slot), with **N pickup; keys up to 33 past the
last BLF are cleared so removed BLFs leave the phone. Both vendors get
the time zone and NTP, and a phonebook URL that carries the phone's
credentials (phones fetch it like the config).

Time zones: packages/shared/src/phone-timezones.ts maps the US/Canada
zones (with aliases), UTC and London to Yealink's offset and name and
a POSIX string (Grandstream P64). Anything else is UTC, and the Phones
page warns.

/prov/phonebook/<mac>.xml (and <mac>/phonebook.xml, the name
Grandstream asks for under its path): every extension, then the
company contacts that have numbers, in the phone's vendor's format.
Same Basic auth as the config.

The config preview now masks both secrets wherever they appear (the
phonebook URL carries the provisioning password) and says when the
time zone falls back.

Golden files for both templates and both phonebooks in
src/provisioning/__golden__. e2e m10-t2: BLF keys, Central time, the
phonebook fetched from the URL in the config, hints for the BLF
targets, and the time zone warning. Vendor key names stay (verify per
model) until a real phone is checked.

Task: M10-T2
2026-10-19 13:48:34 -05:00
iamdoubz fa63908261 provisioning: phones, /prov with Basic auth, rate limit and IP block
ci / check (push) Successful in 52s
ci / images (push) Successful in 1m44s
/api/devices CRUD (MAC in any format → 12 lowercase hex; a 20-char
provisioning password, stored encrypted and shown to admins, who type
it into the phone) and GET /:id/config-preview with passwords masked.

GET /prov/... per 07 §2: <mac>.cfg (Yealink), cfg<mac>.xml
(Grandstream), the empty Yealink common file. Basic auth with the MAC
and the device's password (timing-safe), 404 for unknown MACs or the
other vendor's file, 403 outside the local nets when LAN-only, 404 when
provisioning is off, Cache-Control no-store, last provisioned time and
IP recorded. Limits: 20 requests / 10 min per IP outside the local nets
(an office's phones rebooting together would pass it), and 10 failures
block the IP for an hour. A first request without credentials isn't a
failure: phones ask that way and retry after the 401.

Templates (pure functions of a ProvisionContext) hold the account,
voicemail, pickup codes and the provisioning settings; BLF, time zone
and phonebook come with M10-T2.

Web: Phones page (table, drawer with what to type into the phone,
config preview; manual settings for generic phones). e2e m10-t1: phone
added in the UI, 401 then the file, and a baresip phone registers with
only the account from it.

Also: the loadSnapshot test made its database inside the test (20 s);
under a full parallel run that timed out now and then. It now does it
in a hook like the others.

Task: M10-T1
2026-10-19 13:38:18 -05:00
iamdoubz dc79120de0 roadmap: park SMS (M9-T2, M9-T3) until the owner wants it
ci / check (push) Successful in 53s
ci / images (push) Successful in 20s
The owner's voip.ms account has no SMS. M9-T1 (API client, number
import, server list) stays: it doesn't need SMS. Its check against a
real account (API enabled, this server's IP allowed) is still open.
Nothing in later milestones depends on SMS.

Task: M9-T1
2026-10-19 13:21:59 -05:00
iamdoubz 33b1456c0f voipms: API client, POP server list, import numbers
integrations/voipms/client.ts: voipmsApi(url, creds, method, params)
per 06 §5.1 (GET rest.php, 15 s timeout, status "success" or an error
code). The API password is only in the query string: never logged,
never in an error. Known codes get a message for people
(invalid_credentials, ip_not_enabled, ...). getServers and getDids
parse forgivingly; no_did is an empty list.

Routes: GET /api/trunks/:id/voipms-servers, GET and POST
/api/trunks/:id/import-dids. Import adds only numbers voip.ms lists
and that aren't added yet, label from the description, destination
hangup. 400 without API credentials, 502 provider_error with voip.ms's
reason. VOIPMS_API_URL overrides the endpoint for tests.

Web: the trunk form's server list comes from voip.ms once the trunk
has API access, falling back to the built-in list with the reason.
"Import numbers from voip.ms" sits in the trunk form's API section and
the setup wizard's numbers step. Saving a new API password asks
voip.ms again (found in e2e: the old error stayed).

The response shapes are from the 06 spec, not a real account: voip.ms's
API docs need a login. Marked TODO(spec) and (verify) in 06. Tests use
a fake rest.php; e2e m9-t1 too (trunk created off, so nothing registers
to the real voip.ms).

Task: M9-T1
2026-10-19 12:41:45 -05:00
iamdoubz 4742ed52ea queues: service level counts callers who weren't answered
ci / check (push) Successful in 1m4s
ci / images (push) Successful in 19s
withinSlaPercent was "answered within sla_seconds / answered", so a
queue where a caller timed out still showed 100%. It is now the usual
service level: answered within sla_seconds out of every caller the
queue is done with, leaving out those who hung up before sla_seconds
(nobody could have answered them in time). Timed out and turned away
count as missed; callers still waiting aren't counted. Written into
04; the page label says "Callers answered within N s".

Real calls: m8-t1 now shows 50% (1 answered in time, 1 timed out, the
early hang-up left out) and the milestone check 67% (2 of 3), and
asserts it. The check moves to queue 552 (*45552) so it doesn't clash
with m8-t1's 550 on a shared database.

Task: M8-T1
2026-10-19 12:13:39 -05:00
iamdoubz 088372bff8 e2e: M8 milestone check
ci / images (push) Successful in 19s
ci / check (push) Successful in 1m6s
scripts/e2e/m8-check.mjs, the scenario from 09 M8 with real calls from
a DID: queue 550 with agents 340 and 341, max wait 30 s, then 342. 340
pauses with *45550 and 341 is paused by an admin, so three callers
wait. 341 comes back and gets the longest waiting caller, parks them
on 701 (blind transfer to 700) and 343 picks up by dialing 701; 341
takes the next caller and the last one rings 342 after 30 s. Stats:
3 offered, 2 answered, 1 timed out.

Roadmap marks the M8 milestone check done; the e2e README describes
m8-t4, m8-t5 and m8-check.

Task: M8-check
2026-10-19 11:26:54 -05:00
iamdoubz b8ff6e3386 moh: music-on-hold classes, uploads, pickers for users and queues
ci / check (push) Successful in 51s
ci / images (push) Successful in 18s
/api/moh-classes: create (slug, not "default"), delete (409 while an
extension or queue uses it), add/play/remove files. Files are 8 kHz
wav in MOH_DIR/<class>/ and sound_files rows of kind moh. Extensions
and queues may only pick default or an existing class.

Two things found with real calls, written back into 03 and 04:
- An empty class plays silence on Asterisk 24. Only classes with
  music are rendered, so a channel asking for an empty one gets
  default.
- The generator reloads only changed files, and a file upload changes
  no config (except a class's first/last). The route sends moh reload
  after every file change.

Web: a Music on hold section on Sounds & music, and a select of sets
in the user drawer's Phone tab and the queue form. load-golden makes
the class folders like the api does. Golden case queue gains two
classes. e2e m8-t5: set made in the UI, upload listed by Asterisk, a
queue caller hears it, a user's moh_suggest, delete refused while
used, an emptied set falls back to default.

Task: M8-T5
2026-10-19 10:10:01 -05:00
iamdoubz e95271e694 paging: paging groups, *80 intercom, auto-answer headers
ci / check (push) Successful in 1m7s
ci / images (push) Successful in 3m20s
/api/paging-groups CRUD (members as memberIds). The generator renders
sp-page-{id} per 03 §6.11 (desk phones only, auto-answer headers via
sp-sub-autoanswer) plus the number in sp-local-numbers. Added the `s`
option so phones already on a call aren't paged, which also keeps a
pager who is a member from being paged themselves. A group with no
members just hangs up.

*80<ext> intercom checks the target first: digits only and an endpoint
in sp-from-ext, else ss-noservice (the spec's one-liner passed
${EXTEN:3} straight into Page()). Both written back into 03.

Web: Paging page (list, drawer form with member checkboxes). Golden
case paging. e2e m8-t4 with baresip phones that honor the headers
(sip_autoanswer): an ordinary call rings, a page is answered by itself
and muted, the busy pager is skipped, *80 is two-way, *80999 refused.
The check on a real Yealink/Grandstream stays open (09).

Task: M8-T4
2026-10-19 09:40:22 -05:00
iamdoubz a6c423de97 conferences: rooms with PIN and admin PIN, per-room limits, recording
ci / images (push) Canceled after 0s
ci / check (push) Canceled after 3s
/api/conferences CRUD: PINs stored encrypted, shown to admins (they hand
them out), never in the audit log; an admin PIN needs a different room
PIN (checked against the stored values on PATCH). The generator renders
sp-conf-{id} per 03 §6.11 (PIN prompt, 3 tries, admin PIN joins with
sp_admin_user), the number in sp-local-numbers and destApp conference.

Two changes from the spec's snippet, both found with real calls and
written back into 03:
- Set(CONFBRIDGE(bridge,max_members)) is ignored once ConfBridge()
  names a bridge profile: a 4th caller got into a room of 3. Each room
  now has its own [sp_room_<n>](default_bridge) profile in
  confbridge_selfphoneng.conf.
- A recorded room runs sp-sub-record on each caller's leg (just before
  ConfBridge) instead of record_conference, so the files are ordinary
  call recordings: call history, Recordings page, retention.

Web: Conference rooms page (list, drawer form), "Conference room" in
the DestinationPicker. Golden case conferences. e2e m8-t3: room built
in the UI, wrong PIN refused and asked again, admin PIN joins with
flags AM, a 4th caller turned away, recordings listed, and a no-PIN
room with two desk phones.

Task: M8-T3
2026-10-19 04:56:41 -05:00
iamdoubz 863a9d121c parking: parked calls live, on the dashboard and Home, pick up from Home
ci / images (push) Canceled after 0s
ci / check (push) Canceled after 3s
res_parking was configured in M0 (700, slots 701-720, slot hints); this
makes parked calls visible. ParkingTracker loads ParkedCalls and follows
ParkedCall/UnParkedCall/ParkedCallTimeOut/ParkedCallGiveUp; everyone
gets parked.updated/parked.removed. Who parked comes from
ParkerDialString.

A blind transfer to 700 parks without running Park() in the dialplan,
so the live call's "parked" state now comes from those events by
ParkeeLinkedid; the picking-up phone runs ParkedCall() while talking,
which no longer counts as parked.

Web: "Parked calls" on the Dashboard and on Home, where "Pick up 701"
calls the slot like Call back (softphone, or the desk phone first).

CDR fixture park-pickup (recorded: 101 parks an inbound call, 103 picks
it up); the call counts as answered by 101 with 103 in rang. e2e m8-t2
with real phones: park by blind transfer, hint InUse, both lists, pick
up from Home, a second park on 702 (findslot = next) picked up by
dialing it from another phone. Specs 01/04/05 note what was verified.

Task: M8-T2
2026-10-19 04:23:21 -05:00
iamdoubz 2d9f8ed99e queues: queues, agents, *45 login, live tiles, stats
Queues per 03 §6.8: queues_selfphoneng.conf (members as Local agents
following their hint), sp-queue-{id} (answer, prefix, record, Queue with
max wait, timeout destination), the number in sp-local-numbers and
destApp queue. Golden case queue; the Asterisk load test now checks
`queue show` lists each queue's members.

*45<queue> toggles login and checks PQMSTATUS/UPQMSTATUS, so a code for
a queue the caller isn't an agent of plays ss-noservice and stores
nothing (spec snippet fixed). Pauses live in queue_member_state, AstDB
SP/QPAUSE (resynced on connect) and app_queue; QueuePause is re-sent
after every `queue reload all` and rollback, which unpause everyone.

/api/queues CRUD with members [{extensionId, penalty}], admin
pause/unpause, GET /:id/stats; /api/me/queues/:id/pause|unpause for
Home. Stats come from queue_calls (new table), filled from app_queue's
AMI events plus UserEvent SpQueueExit in the fail branch: a real call
showed that when no agent rings, the Queue part of a call vanishes into
the next leg's CDR, and Asterisk 24 sends QueueCallerAbandon on
timeouts too.

Live: QueueTracker (QueueStatus on connect and after applies, queue
events); admins see every queue, users the queues they're agents of.
Web: Queues page (list, form with every column, detail page with
waiting callers, agents and pause buttons, today's stats), queue tiles
on the dashboard, queue login switches on Home, Queue in the
DestinationPicker.

CDR fixtures for queue answered/abandoned/timeout calls. e2e m8-t1 with
real phones: 3 callers wait with 2 agents, one paused by *45550 and one
in the UI, unpause connects the longest waiting caller, one times out
to the overflow phone, stats count it all. The whole e2e chain passes.

Task: M8-T1
2026-10-18 20:15:51 -05:00
iamdoubz f7fc4ed70e live: calls in progress, dashboard list, admin hang up and transfer
CallTracker groups channels by Linkedid from channel events (loaded from
CoreShowChannels on connect); call.updated/call.removed go to admins.
The dashboard lists calls now with state and timer, hang up (confirm)
and transfer to an internal number (AMI Redirect of the caller), both
audited. e2e m7-t4: 3 concurrent calls match core show channels.

Task: M7-T4
2026-10-18 15:06:57 -05:00
iamdoubz 4b6b95090a recordings: record calls, link them to history, Recordings page
sp-sub-record now also sets CDR(SP_REC); phone numbers can record every
call (record_inbound), with the notice from Settings → Recording played
first (SP_REC_ANNOUNCE). Aggregation links the MixMonitor file as a
recordings row (size, ffprobe length). /api/recordings lists, plays
(Range; admins and the people on the call) and deletes; recordings past
recordings.retentionDays are deleted daily.

Web: Recordings page (play, download, delete), a player in Call history
and My calls, a Recording settings tab with the consent notice (08 §5),
and "Record every call" on phone numbers.

e2e m7-t3: the notice plays before a recorded inbound call and the
recording plays from Call history (the M7 acceptance). The AMI
Originate helper moved to scripts/e2e/ami.mjs.

Task: M7-T3
2026-10-18 14:57:52 -05:00
iamdoubz 8abcea9533 calls: call history API and pages, CSV export, missed calls
/api/calls (admin): filters by company-time-zone days, direction,
status, user, phone number and number search; newest first with a
keyset cursor; /:id adds the raw CDR legs; /export.csv with the same
filters (RFC 4180, spreadsheet-formula cells quoted). /api/me/calls
lists my calls with my side (in/out) and whether I missed it.

Web: admin Call history (filters, Load more, a row expands to its legs,
Export CSV), My Phone → Calls (click a number to call back: softphone
or desk phone first), and Missed calls on Home.

e2e m7-t2 with real calls found that a call going straight to voicemail
(DND) wasn't in the user's history: the mailbox owner of a VoiceMail
leg now counts as rang (and the IVR fixture's expectation follows).

Task: M7-T2
2026-10-18 14:46:32 -05:00
iamdoubz 71440ac85d cdr: store Cdr events, aggregate calls, fixtures from real calls
services/cdr.ts stores every AMI Cdr event in cdr and, 5 s after a
call's last event, builds its calls row (direction, status, from, to,
DID, answered by, talk time) and call_extensions (caller, rang,
answered) per 04 §5.4; linkedids without a calls row are caught up on
AMI connect. Aggregation is idempotent.

test/asterisk/cdr-record.ts records 8 scenarios with our config, the
fake voip.ms (now shared in provider.ts) and baresip phones; each has a
fixture and a test with the expected calls row. Queue abandon and park
come with M8.

Found while recording: Asterisk 24's Cdr event has no LinkedID unless
cdr_manager maps it, its times carry no zone (now usegmtime), and the
mappings read CDR variables, so the dialplan sets CDR(SP_DID) and
CDR(SP_DIRECTION) next to the channel variables. The Asterisk image
needs a rebuild for the cdr_manager.conf change.

Task: M7-T1
2026-10-18 14:37:14 -05:00
iamdoubz 57e0e3dac2 featurecodes: DND, forwarding, pickup, echo, say-ext; /me settings
Feature codes per 03 §6.13: *78/*79 DND, *72<number>/*73 forward always,
**<ext> directed pickup, *8 group pickup (also as a dialplan line, since
pickupexten only catches a phone's own INVITE), *43 echo test, *65 say
my extension; queue login and intercom stay for M8. SpToggle DND/CFU
update user_runtime_state; a *72 target the user's calling permission
doesn't allow is removed from AstDB, audited (forward_rejected) and sent
to the user as a toast (new private live message). Forward targets are
checked the same way everywhere (services/forwards.ts: an existing
internal number, or an outside number some outbound rule allows them).

GET/PATCH /api/me: DND, cfu/cfb/cfna, voicemail email options and PIN;
PostgreSQL first, then AstDB; AstDB resync on connect now includes DND
and forwards. Web: /me/settings (forwarding, DND, voicemail PIN and
email options), a status card on Home, toasts.

e2e m6-t7 with real phones covers every code, the refused forward toast,
and the settings page reaching AstDB; it found the settings forms losing
"Saved." on remount.

Task: M6-T7
2026-10-18 11:19:21 -05:00
iamdoubz d746772b97 destinations: full picker everywhere, numbers in summaries, Ring everyone
The DestinationPicker now offers every type that exists (user, voicemail,
ring group, digital receptionist, office hours, announcement, outside
number, hang up) on every page that routes calls; queues and
conferences join in M8. API summaries show the number of dialable
targets ("Ring group Sales (500)", 05 §3). Wizard step 8 gains "Ring
everyone": it creates or refreshes a ring group "Everyone" of all
extensions (unanswered → the first user's voicemail); an IVR isn't
created there because it needs a recorded greeting. m3-t5 now routes
the main number through "Ring everyone" and still passes.

Task: M6-T6
2026-10-18 11:04:42 -05:00
iamdoubz dd640b368b announcements: play a sound, then continue
/api/announcements CRUD (sound must be a prompt, destination must exist
and not be itself, delete refused while referenced); the generator
renders sp-ann-{id} (Answer, Playback, destApp) and destApp sends
announcement destinations there. The ivr golden case gains "press 3:
hours announcement, then back to the menu". Announcements are managed in
a section of Sounds & music (05 updated); the DestinationPicker gains
"Announcement".

Task: M6-T5
2026-10-18 11:00:33 -05:00
iamdoubz 108547046c timeconditions: office hours, holidays, override codes, weekly grid
/api/time-conditions (company time zone by default, ranges validated,
unique override digit, never pointing at itself) with a live status
("open/closed/holiday now", same logic as the dialplan, shared), and
POST /:id/override, which writes PostgreSQL then AstDB SP/TCOVR.
/api/holidays CRUD plus import-us-federal (observed dates, computed in
code, skips days already listed). services/astdb.ts resyncs AstDB on
every AMI connect (PostgreSQL wins); services/toggles.ts applies
SpToggle TCOVR from *31N/*32N/*33N.

Generator: sp-tc-{id} per 03 §6.10 (override, holidays within the next
year, ranges rendered a minute short because GotoIfTime's end is
inclusive), sp-tc-override with a no-service fallback, admin-only
*3[123]X in sp-featurecodes, destApp time_condition. The date changing
in the company time zone triggers a forced apply so holidays roll over.
Golden cases holidays and ivr-business-hours.

Office hours page: click-and-drag half-hour grid plus an exact range
list, destinations, holiday destination, override key, status chip with
Force open / Force closed / Automatic; Holidays tab with the US federal
import. e2e m6-t4 found two bugs (pointer capture release throwing,
drawer overflow) and checks routing, AstDB, *321/*331 from an admin
phone, refusal for others, and the import reaching the dialplan.

Task: M6-T4
2026-10-18 10:56:28 -05:00
iamdoubz 15624f7b77 ivrs: digital receptionist with keypad grid, SoundPicker, golden case
/api/ivrs CRUD: greeting and wrong-key sounds must be prompts, every
option destination must exist (an option may point at its own menu),
keys unique; delete drops the menu's own options before checking
references, inside a transaction so a refused delete keeps them. The
generator renders sp-ivr-{id} per 03 §6.9 (Background greeting,
WaitExten, keys sorted, i/t retry then give up, include sp-local-numbers
for direct dial), adds the number to sp-local-numbers, and destApp sends
ivr destinations there. Golden case ivr (incl. # and self-reference).

Web: Digital receptionist page with a 3x4 keypad grid (pick a key, then
its destination or "unused"), SoundPicker (pick, play, upload new right
in the form), DestinationPicker gains "Digital receptionist" and a shared
target source; useDescribeDestination labels unsaved keys.

e2e m6-t3: menu built in the UI, the greeting plays, keys 1/2 ring the
right phones, a wrong key plays pbx-invalid and repeats the greeting.

Task: M6-T3
2026-10-18 10:39:13 -05:00
iamdoubz f87d4d47dd ringgroups: ring-all and hunt groups, API, page, golden case
/api/ring-groups CRUD with members as ordered memberIds (unique, must
exist), number from the ring-group range, a no-answer destination
(never itself), and delete refused while something points at it. The
generator renders sp-rg-{id} (Dial through sp-rg-member: all at once with
the total ring time, or one at a time with the per-member time, music
instead of ringing on request), adds the number to sp-local-numbers, and
destApp sends ring_group destinations there. Golden case ringgroups.

The DestinationPicker is now generic over targets picked by id and gains
"Voicemail of a user" and "Ring group" (M6-T6 completes it). Caller-name
prefixes (DIDs and ring groups) get a space before the caller's name.
Ring groups page with an ordered member list.

e2e m6-t2 found the new-group form failing on an unset no-answer
destination (fixed: defaults to hang up). It checks ring-all (both ring,
191 answers) and hunt (190 alone for 5 s, then 191) with real calls. The
generator debounce test now waits for the apply instead of a fixed 150 ms.

Task: M6-T2
2026-10-18 10:31:05 -05:00
iamdoubz 17db68a8c5 sounds: upload, convert, play, rename, delete with references
/api/sounds (admin) takes the raw file, converts it with ffmpeg in one
pass to <id>.wav (8 kHz) and <id>.g722 (HD for G.722 calls) in SOUNDS_DIR,
measures it with ffprobe, and stores it as custom/<id>. Delete answers 409
with what still plays it (IVR greeting/invalid sound, announcements, the
recording notice). Sounds & music page: drop or pick a file (name
suggested from the file name), play, rename, delete. MOH classes stay in
M8. The web client and greetings share one raw-upload helper; the
api test audio helpers moved to test/audio.ts.

e2e m6-t1: drag-and-drop upload in the browser, Asterisk plays
custom/<id>, rename, delete refused while in use, then deleted.

Task: M6-T1
2026-10-18 10:13:58 -05:00
iamdoubz 609965bba5 softphone: People panel with presence, click-to-call
ci / images (push) Canceled after 0s
ci / check (push) Canceled after 4s
People lists every extension with its status dot and a search box: a
right-hand column on wide screens, /me/people on small ones. Call uses the
browser softphone when it's on, else POST /api/me/click-to-call, which
originates to the user's desk phone first (409 if none is registered).
During a call: Transfer (blind), Talk first (consult), or drag the call
card onto a person; a held call card offers Complete transfer whenever
another call is active. useSoftphone moved to its own module.

e2e m5-t3: the dot changes ~30 ms after Asterisk's hint; click-to-call
rings 174 before 172. cdp click() takes a scope (People also has
Call/Transfer buttons). trunk-test ignores the RTCP-at-teardown EPERM
error seen once.

Task: M5-T3
2026-10-18 10:06:28 -05:00
iamdoubz c7fd4df77c softphone: dock panel, incoming dialog, hold/mute/DTMF, transfers, one tab
The dock opens a panel with a 12-key dialpad (typing, paste normalization,
Enter) and microphone/speaker selection. Call cards have mute, hold,
keypad (DTMF) and transfer: "Transfer now" (blind) or "Talk first"
(attended, with Complete/Cancel). An incoming call opens a dialog when
idle, or a card during a call (call waiting holds the first call). A
synthesized WebAudio ringtone and desktop notifications, auto-answer for
Call-Info answer-after=0, and a poor-connection warning from getStats.
One tab registers: a Web Lock plus a BroadcastChannel "Use here".

Found by e2e: Chrome can report canInsertDTMF=false, so DTMF falls back to
SIP INFO; after a REFER's 2xx NOTIFY the browser sends BYE itself (RFC
5589), otherwise SIP.js keeps the call up. apps/web/TESTING.md is the
12-step manual run for Chrome and Firefox (still open, roadmap note);
e2e m5-t2 automates most of it in headless Edge.

Task: M5-T2
2026-10-18 09:43:41 -05:00
iamdoubz ae3a723a3c softphone: SIP.js engine, /api/me/webrtc, minimal dock
GET /api/me/webrtc returns the <ext>-web identity, WebSocket URL
(WEBRTC_WS_URL overrides wss://PUBLIC_HOSTNAME/ws) and STUN/TURN servers.
SoftphoneEngine wraps SIP.js 0.21.2: register with backoff reconnect,
call, answer, decline, hang up, remote audio. A minimal dock on /me
enables it (asks for the microphone on the click) and dials/answers;
M5-T2 grows it into the full panel.

Generator: ice_host_candidates keeps the LAN address
(include_local_address), so LAN browsers don't need hairpin NAT, and -web
endpoints set from_domain = selfphoneng.invalid because SIP.js drops
INVITEs whose From host starts with a digit (Docker/VPS hostnames).

e2e m5-t1 (Docker): browser registers as 170-web and has two-way audio
with a baresip phone both ways. The LAN and hotspot check on the owner's
server is still open (roadmap note).

Task: M5-T1
2026-10-18 09:27:45 -05:00
iamdoubz f60dda0bb0 me: voicemail page, greetings, My Phone home
ci / images (push) Canceled after 0s
ci / check (push) Canceled after 25s
/api/me/voicemail lists, streams (Range, download) and marks read or
deletes through AMI VoicemailMove/VoicemailRemove, then rescans so MWI
and the list agree. /api/me/greetings takes the raw recording or file and
converts it with ffmpeg to 8 kHz PCM (media/audio.ts). My Phone gets a
layout with Home (new voicemails) and Voicemail (play marks read,
download, delete, record/upload greetings via MediaRecorder); the list
refetches on the live voicemail.new event. e2e m4-t3 checks MWI goes off
within 5 s after play and delete, and a browser-recorded greeting is what
callers hear.

Task: M4-T3
2026-10-17 22:56:10 -05:00
iamdoubz 8d752ba705 voicemail: index messages, live event, email with the recording
services/voicemail.ts rescans a mailbox on Asterisk's MessageWaiting event
(and every mailbox on AMI connect) instead of watching files, upserts
voicemail_messages by msg_id, marks vanished ones deleted, sends
voicemail.new to the owner only, and emails the wav. Delete-after-email
uses AMI VoicemailRemove, and only when the audio was attached. Asterisk
24 has VoicemailMove/VoicemailRemove, so 04 §5.6 drops the renumbering
plan. New VOICEMAIL_DIR setting; e2e m4-t2 checks the email arrives
within 10 s with a playable attachment (SMTP sink shared with m3-t4).

Task: M4-T2
2026-10-17 22:33:52 -05:00
iamdoubz 0d29131d18 voicemail: mailbox config, voicemail on no-answer/busy, *97/*98, MWI
Generate voicemail_selfphoneng.conf (03 §7) with one mailbox per extension
that has voicemail on, turn on ARG3 in sp-sub-user, and add *97/*98 to
sp-featurecodes (which replaces the pending placeholder). Endpoints with a
mailbox get mwi_subscribe_replaces_unsolicited so phones that SUBSCRIBE for
MWI aren't refused with a 500. A new test:asterisk check leaves a message,
sees MWI on a baresip phone, plays it with *97 and sees MWI turn off.

Task: M4-T1
2026-10-17 22:21:53 -05:00
iamdoubz 1e50d12c6e docs: track open real voip.ms acceptance checks for M3
Task: M3-T1..T3
2026-10-17 22:07:42 -05:00
iamdoubz c2b9fff126 setup: first-run wizard, telephony restart, system status
ci / check (push) Canceled after 0s
ci / images (push) Canceled after 0s
/setup walks a fresh install to working calls: password (done by the
forced change), company, network (detect public IP, LAN IP from
/api/system/status, router checklist; restarts telephony because the
transports changed), email (skippable), voip.ms trunk (Next unlocks on
Registered), phone numbers, users (extension auto-numbered, temporary
passwords shown once), main number routing, emergency (default E911
caller ID required once a trunk exists), done. Steps 7-8 ship now
because the acceptance needs a user and a routed number; their M6
choices (everyone, IVR) come later. Admins land on /setup until it is
finished or skipped.

api: /api/setup/status and /complete, POST
/api/settings/network/detect-public-ip (ipify, then ifconfig.me),
GET /api/system/status (M3 subset), POST /api/system/asterisk/restart
(an AMI disconnect during a restart counts as success).
restartRequired clears on AMI connect once Asterisk's uptime shows it
started after the change. The first phone number becomes the default
outbound caller ID when none is set (found by the acceptance run:
calls otherwise left with the provider's caller ID).

Verified: scripts/e2e/m3-t5.mjs on a fresh database against the fake
voip.ms: wizard end to end, then an inbound call to the DID rings the
new user's softphone and an outbound call leaves as 13125550000 with
the DID in Remote-Party-ID. m1-t4 now passes through "Skip setup".

Task: M3-T5
2026-10-17 21:11:04 -05:00
iamdoubz 409724bc55 emergency: 911 dialplan, notifications, test button, settings, warnings
Dialplan: sp-emergency-dial (911, 9911) first in sp-from-ext;
sp-emergency has no class, channel-limit or DND check, dials every
enabled trunk in name order with the E911 caller ID, and raises
UserEvent SpEmergency. Static sp-emergency-alert (_X! so "0" matches)
for the ring-out.

api: SpEmergency -> emergency_events row, live emergency.call (admins
and notify users only), email via nodemailer (SMTP from settings) and
an Originate to every notify extension, each outcome recorded. Test
button runs the same with TEST in front, records and dials nothing.
GET/PATCH /api/settings (smtp password write-only, references
checked, read-only system keys), SMTP test, /api/system/warnings.
Fix: clearing a nullable setting stored SQL NULL (now JSON null).

UI: Settings page (Email, Emergency with the 06 §6 notice), user
Emergency tab, dashboard warnings banner, red 911 banner.

Verified: generator/emergency.test.ts; trunk-test.ts dials 911 from a
caller with no class to the fake provider (E911 caller ID in RPID,
SpEmergency on AMI, alert plays on baresip 101); scripts/e2e/m3-t4.mjs
(SMTP sink receives the test email, 101 rings, banner shows).
nodemailer pinned to 10.0.11 (newest release over two weeks old).

Task: M3-T4
2026-10-17 20:52:50 -05:00
iamdoubz 4496f6b89a outbound: routes with failover and caller ID, test box, Outbound rules page
Outbound routes CRUD, reorder and POST /test (mirrors the dialplan:
911 and internal numbers first, then the first matching enabled route,
class check, usable trunks, caller ID priority route > user > default).
The first trunk creates the default rules: toll-free first (because
1NXXNXXXXXX also matches 1800...), US/Canada, international disabled.

Generator: Asterisk orders patterns in one context by specificity, not
by file order, so each route gets its own sp-or-{id} context and
sp-outbound-routes includes them in position order (spec 03 §6.12
updated). sp-route-{id}: class check -> ss-noservice, caller ID
priority, per-trunk GROUP channel limit, failover on CHANUNAVAIL or
CONGESTION, all-circuits-busy-now when every trunk is full or off.

UI: Outbound rules page (↑/↓ reorder, pattern rows with help, ordered
trunk list, caller ID override, test box) and a Calling tab on users
(calling permission, outbound caller ID).

trunk-test.ts: 101 dials 3125550000 -> the fake provider receives
13125550000 with the DID in Remote-Party-ID (From user is the
sub-account), two-way audio; a caller without a class hears
ss-noservice. scripts/e2e/m3-t3.mjs covers the UI. With real voip.ms
still to do: whether per-call caller ID is honored (06 §4).

Task: M3-T3
2026-10-17 20:04:35 -05:00
iamdoubz c117d8bb1d dids: phone numbers, destinations, inbound dialplan, Phone numbers page
DIDs CRUD (any input format, normalized; 409 on duplicates; a number
that is an emergency or default caller ID can't be removed). Shared
destination service: assertDestinationExists, describeDestination
("Ring Dan Doe (101)") and findReferences over every destination
column in 02 §1 plus the unmatched-number setting; deleting an
extension a destination points at is now a 409 naming the places.

Generator: destApp (user, external, hangup for now), sp-from-trunk-*
per enabled trunk, sp-did-exists (left out when empty) and sp-inbound
with caller-name prefix. Golden case voipms-basic. Phone numbers page
with a minimal DestinationPicker (user, outside number, hang up).

trunk-test.ts: the fake provider calls the DID with a 10-digit
Request-URI like voip.ms; baresip 101 answers, caller ID arrives
normalized, 500 packets each way on both legs. scripts/e2e/m3-t2.mjs
checks the UI → Asterisk path. Still to do with real voip.ms: the
mobile-to-DID call and recording which header carries the DID (06 §3).

Task: M3-T2
2026-10-17 19:52:49 -05:00
iamdoubz 1b7f60b5f2 trunks: CRUD API, PJSIP trunk config, live registration status, Trunks page
voip.ms-first trunk form with the portal checklist, POP server list
and Test button; passwords are write-only and encrypted. Registration
and IP-auth trunks render per 03 §5.3 (TCP/TLS add ;transport=). The
API refuses a server name that doesn't resolve and transports that are
off, because either makes Asterisk drop the trunk.

Live state: registration from PJSIPShowRegistrationsOutbound (re-read
on every Registry event: two sub-accounts on one server share
Username/Domain), reachability from PJSIPShowEndpoint and trunk
ContactStatus (static contacts aren't in PJSIPShowContacts). Trunk
state is admin-only. Dashboard gets a "Trunks up" card.

trunk-test.ts (in pnpm test:asterisk) runs a fake voip.ms (second
Asterisk): the good trunk registers, a wrong password is Rejected,
qualify reaches the provider; its recorded AMI drives the unit tests.
scripts/e2e/m3-t1.mjs does the same through the UI. The acceptance
check with a real voip.ms sub-account is still to do.

Tests: settle() now awaits an apply instead of a fixed sleep (flaked
under full-suite load).

Task: M3-T1
2026-10-17 19:42:14 -05:00
iamdoubz c0c4036887 live: AMI-fed live state, /api/live WebSocket, dashboard and status dots
ci / check (push) Canceled after 0s
ci / images (push) Canceled after 0s
LiveState loads extension states and registered devices from Asterisk on
every AMI connect and follows ExtensionStatus/ContactStatus; the hub sends
a snapshot then deltas batched every 250 ms (devices for admins only),
pings every 30 s and closes unauthenticated sockets with 4401. The web UI
keeps one reconnecting socket in a zustand store, shows a Reconnecting
banner, a Dashboard (online, on a call, telephony engine) and status dots.

Fix: Asterisk 24 has no 'pjsip reload'; reload with
'module reload res_pjsip.so'. AMI errors now carry the CLI output.

Milestone check, automated: call-test.ts registers two baresip phones and
holds a 30 s call with RTP both ways (pnpm test:asterisk); the recorded
session drives the live-state tests. scripts/e2e/m2-live.mjs runs api +
Asterisk + phones + browser on a private Docker network: a new extension
is live in Asterisk in ~1.3 s, the dashboard shows 2/2 online and red dots
during the call.

Task: M2-T6
2026-10-17 16:49:25 -05:00
iamdoubz 06ac99b9f4 generator: PJSIP transports, extension endpoints and rtp config
Golden cases empty, one-user, webrtc-disabled and hostile-names; each also
renders identically from shuffled input. pnpm test:asterisk boots the
asterisk image with every golden case and fails on a missing endpoint or
context or any ERROR log line (checked against a deliberately broken case).
On 24.0.0-rc2: 101 and 101-web load with every set_var.

Task: M2-T3
2026-10-17 16:23:14 -05:00
iamdoubz 70b0a92666 ami: TCP client with parser, list actions, ping and reconnect backoff
Parser tests replay a real Asterisk 24 session recorded byte for byte, in
every chunking from 1 byte up. Client tests use an in-process fake AMI.
Checked live against the container: login, command, hard kill rejects the
pending action, reconnect follows the 1/2/4/8/16/30 s backoff.

Task: M2-T1
2026-10-17 16:12:25 -05:00
iamdoubz 15b8a20751 db: drizzle schema matching spec 02, initial migrations, settings service
Migration 0000 enables citext; 0001 creates all 39 tables. DB tests use a
throwaway database per file (TEST_DATABASE_URL, derived from .env in dev,
a postgres service in CI). Decision 0002: pg driver and @fastify/cookie.

Task: M1-T1
2026-10-17 15:04:18 -05:00
iamdoubz d0de0c9039 deploy: docker compose stack, api image, install script, proxy configs
Postgres and AMI secrets are hex (the password goes into DATABASE_URL).

Task: M0-T6
2026-10-17 14:32:57 -05:00
iamdoubz 909ac2d62d asterisk: 24.0.0-rc2 image built from source with static config
Adds stub configs and noloads so a clean boot logs no ERROR lines,
ships stock indications.conf, seeds default MOH from the entrypoint.
Spec 01 synced with the real files.

Task: M0-T5
2026-10-17 14:31:05 -05:00