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
This commit is contained in:
iamdoubz committed 2026-10-19 04:56:41 -05:00
1 parent 964a3c0b25
commit a6c423de97
45 files changed
+1456 -14

No files matched your search

+2
View File
@@ -24,6 +24,7 @@ import { meRoutes } from './routes/me.ts';
import { soundRoutes } from './routes/sounds.ts';
import { ringGroupRoutes } from './routes/ringgroups.ts';
import { queueRoutes } from './routes/queues.ts';
import { conferenceRoutes } from './routes/conferences.ts';
import { ivrRoutes } from './routes/ivrs.ts';
import { timeConditionRoutes } from './routes/timeconditions.ts';
import { announcementRoutes } from './routes/announcements.ts';
@@ -106,6 +107,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
app.register(soundRoutes);
app.register(ringGroupRoutes);
app.register(queueRoutes);
app.register(conferenceRoutes);
app.register(ivrRoutes);
app.register(timeConditionRoutes);
app.register(announcementRoutes);
@@ -0,0 +1,15 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
[sp_admin_user]
type = user
admin = yes
marked = yes
music_on_hold_when_empty = yes
[sp_room_800](default_bridge) ; room 800
max_members = 20
[sp_room_801](default_bridge) ; room 801
max_members = 8
[sp_room_802](default_bridge) ; room 802
max_members = 20
@@ -0,0 +1,5 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_CLASS_101=2
@@ -0,0 +1,285 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
; ─── Normalize a number. ARG1 = raw. Returns normalized in GOSUB_RETVAL. (matches 00 §8)
[sp-sub-normalize]
exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
same => n,ExecIf($["${N:0:2}" = "+1"]?Set(N=${N:1}))
same => n,ExecIf($["${N:0:1}" = "+"]?Set(N=011${N:1}))
same => n,ExecIf($[${LEN(${N})} = 10]?Set(N=1${N}))
same => n,Return(${N})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
[sp-sub-dial-user]
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
same => n,GotoIf($["${DS}" = ""]?unavail)
same => n,Dial(${DS},${ARG2},tx)
same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return()
; ─── Call a user with all their personal rules.
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
[sp-sub-user]
exten => s,1,Set(__SP_TARGET=${ARG1})
same => n,ExecIf($["${SP_DIRECTION}" = "inbound" & ("${ARG4}" = "inbound" | "${ARG4}" = "all")]?Gosub(sp-sub-record,s,1))
same => n,GotoIf($["${DB(SP/DND/${ARG1})}" = "1"]?busy)
same => n,Set(FWD=${DB(SP/CFU/${ARG1})})
same => n,GotoIf($["${FWD}" != ""]?forward)
same => n,Gosub(sp-sub-dial-user,s,1(${ARG1},${ARG2}))
same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done)
same => n,GotoIf($["${DIALSTATUS}" = "BUSY"]?busy)
same => n(noanswer),Set(FWD=${DB(SP/CFNA/${ARG1})})
same => n,GotoIf($["${FWD}" != ""]?forward)
same => n,GotoIf($["${ARG3}" = "1"]?vmu)
same => n,Return()
same => n(vmu),VoiceMail(${ARG1}@default,u)
same => n,Return()
same => n(busy),Set(FWD=${DB(SP/CFB/${ARG1})})
same => n,GotoIf($["${FWD}" != ""]?forward)
same => n,GotoIf($["${ARG3}" = "1"]?vmb)
same => n,Busy(5)
same => n,Return()
same => n(vmb),VoiceMail(${ARG1}@default,b)
same => n,Return()
same => n(forward),Gosub(sp-sub-forward,s,1(${FWD},${ARG1}))
same => n(done),Return()
; ─── Forward a call. ARG1 = target number, ARG2 = ext whose forward rule this is.
; Stops after 3 hops so 101→102→101 can't loop forever.
[sp-sub-forward]
exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} + 1])
same => n,GotoIf($[${SP_FWDHOPS} > 3]?stop)
same => n,GotoIf($[${DIALPLAN_EXISTS(sp-local-numbers,${ARG1},1)}]?internal)
same => n,Set(SP_CLASS=${GLOBAL(SP_CLASS_${ARG2})})
same => n,Set(__SP_FORWARDED=1)
same => n,Goto(sp-outbound-routes,${ARG1},1)
same => n(internal),Goto(sp-local-numbers,${ARG1},1)
same => n(stop),Return()
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
[sp-sub-record]
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
same => n,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
same => n,MixMonitor(${SP_REC},b)
same => n(done),Return()
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
[sp-sub-autoanswer]
exten => s,1,Set(PJSIP_HEADER(add,Call-Info)=<sip:${SP_EXT}>\;answer-after=0)
same => n,Set(PJSIP_HEADER(add,Alert-Info)=info=alert-autoanswer)
same => n,Return()
; ─── 911 alert ring-out. Originated by the api (04 §5.3): Channel PJSIP/<notify ext>,
; Exten = the extension that dialed 911.
[sp-emergency-alert]
exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,Wait(1)
same => n,Playback(beep&beep&beep)
same => n,Playback(extension)
same => n,SayDigits(${EXTEN})
same => n,Playback(beep&beep&beep)
same => n,Playback(extension)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13). Intercom *80 arrives with M8-T4.
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,VoiceMailMain(@default)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
same => n,Playback(do-not-disturb&activated)
same => n,Hangup()
exten => *79,1,Answer() ; DND off
same => n,Set(X=${DB_DELETE(SP/DND/${SP_EXT})})
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
same => n,Playback(do-not-disturb&de-activated)
same => n,Hangup()
exten => _*72X.,1,Answer() ; *72<number> forward always
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
same => n,Playback(call-fwd-unconditional&activated)
same => n,Hangup()
exten => *73,1,Answer() ; cancel forward always
same => n,Set(X=${DB_DELETE(SP/CFU/${SP_EXT})})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
same => n,Playback(call-fwd-unconditional&de-activated)
same => n,Hangup()
exten => _**X.,1,PickupChan(PJSIP/${EXTEN:2}-&PJSIP/${EXTEN:2}-web-) ; directed pickup **101
same => n,Hangup()
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
exten => *8,1,Pickup()
same => n,Hangup()
exten => *43,1,Answer() ; echo test
same => n,Playback(demo-echotest)
same => n,Echo()
same => n,Playback(demo-echodone)
same => n,Hangup()
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
same => n,Set(Q=${EXTEN:3})
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
same => n,GotoIf($["${PQMSTATUS}" != "PAUSED"]?deny) ; not an agent of that queue
same => n,Set(DB(${K})=1)
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 1)
same => n,Playback(agent-loggedoff)
same => n,Hangup()
same => n(login),Set(X=${DB_DELETE(${K})})
same => n,UnpauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
same => n,GotoIf($["${UPQMSTATUS}" != "UNPAUSED"]?deny)
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 0)
same => n,Playback(agent-loginok)
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny)
same => n,Goto(sp-tc-override,${EXTEN},1)
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
[sp-vm-direct]
exten => _X.,1,Answer()
same => n,VoiceMail(${EXTEN}@default,u)
same => n,Hangup()
[sp-ext-forward]
exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external destination
same => n,Set(__SP_FORWARDED=1)
same => n,Goto(sp-outbound-routes,${EXTEN},1)
[sp-rg-member] ; one member of a ring group (Local channel)
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n(skip),Hangup()
[sp-queue-member] ; one queue agent (Local channel)
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n,Hangup()
[sp-from-ext]
include => sp-emergency-dial
include => sp-featurecodes
include => sp-local-numbers
include => sp-outbound-routes
[sp-local-numbers]
include => sp-parkedcalls
; users (sorted by number)
exten => 101,1,Gosub(sp-sub-user,s,1(101,25,1,never))
same => n,Hangup()
exten => 101,hint,PJSIP/101&PJSIP/101-web
; conference rooms
exten => 800,1,Goto(sp-conf-55555555-5555-4555-8555-555555555800,s,1)
exten => 801,1,Goto(sp-conf-55555555-5555-4555-8555-555555555801,s,1)
exten => 802,1,Goto(sp-conf-55555555-5555-4555-8555-555555555802,s,1)
; ─── Conference room 800 "Open room"
[sp-conf-55555555-5555-4555-8555-555555555800]
exten => s,1,Set(__SP_TARGET=800)
same => n,Answer()
same => n,ConfBridge(800,sp_room_800,default_user,default_menu)
same => n,Hangup()
; ─── Conference room 801 "Board private"
[sp-conf-55555555-5555-4555-8555-555555555801]
exten => s,1,Set(__SP_TARGET=801)
same => n,Answer()
same => n,Set(TRIES=0)
same => n(ask),Read(PIN,conf-getpin,10,,1,8)
same => n,GotoIf($["${PIN}" = "917355"]?admin)
same => n,GotoIf($["${PIN}" = "4826"]?user)
same => n,Set(TRIES=$[${TRIES} + 1])
same => n,GotoIf($[${TRIES} < 3]?bad)
same => n,Hangup()
same => n(bad),Playback(conf-invalidpin)
same => n,Goto(ask)
same => n(admin),Gosub(sp-sub-record,s,1)
same => n,ConfBridge(801,sp_room_801,sp_admin_user,default_menu)
same => n,Hangup()
same => n(user),Gosub(sp-sub-record,s,1)
same => n,ConfBridge(801,sp_room_801,default_user,default_menu)
same => n,Hangup()
; ─── Conference room 802 "Team"
[sp-conf-55555555-5555-4555-8555-555555555802]
exten => s,1,Set(__SP_TARGET=802)
same => n,Answer()
same => n,Set(TRIES=0)
same => n(ask),Read(PIN,conf-getpin,10,,1,8)
same => n,GotoIf($["${PIN}" = "1357"]?user)
same => n,Set(TRIES=$[${TRIES} + 1])
same => n,GotoIf($[${TRIES} < 3]?bad)
same => n,Hangup()
same => n(bad),Playback(conf-invalidpin)
same => n,Goto(ask)
same => n(user),ConfBridge(802,sp_room_802,default_user,default_menu)
same => n,Hangup()
; ─── Office hours overrides (admins only, see sp-featurecodes)
[sp-tc-override]
exten => _*3[123]X,1,Playback(ss-noservice)
same => n,Hangup()
; ─── Calls from trunk "voip.ms"
[sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37]
exten => s,1,Goto(sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37,unknown,1)
exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound)
same => n,Set(CDR(SP_DIRECTION)=inbound)
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN}))
same => n,Set(SP_IN=${GOSUB_RETVAL})
same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller)
; Request-URI didn't hold one of our DIDs: try the To header (06 §3)
same => n,Set(TOU=${CUT(CUT(PJSIP_HEADER(read,To),@,1),:,2)})
same => n,Gosub(sp-sub-normalize,s,1(${TOU}))
same => n,Set(SP_IN=${GOSUB_RETVAL})
same => n(caller),Gosub(sp-sub-normalize,s,1(${CALLERID(num)}))
same => n,Set(CALLERID(num)=${GOSUB_RETVAL})
same => n,GotoIf($["${DB(SP/BLOCK/${CALLERID(num)})}" = "1"]?blocked)
same => n,Goto(sp-inbound,${IF($["${SP_IN}" = ""]?unknown:${SP_IN})},1)
same => n(blocked),Hangup(21)
[sp-did-exists]
exten => 13125551234,1,NoOp()
[sp-inbound]
; DID 13125551234
exten => 13125551234,1,Set(__SP_DID=13125551234)
same => n,Set(CDR(SP_DID)=13125551234)
same => n,Goto(sp-conf-55555555-5555-4555-8555-555555555801,s,1)
; anything else
exten => _[0-9a-z].,1,Hangup()
[sp-outbound-routes]
exten => pending,1,Hangup() ; no routes yet
[sp-emergency-dial]
exten => 911,1,Goto(sp-emergency,s,1)
exten => 9911,1,Goto(sp-emergency,s,1) ; people who dial 9 first out of habit
[sp-emergency]
exten => s,1,Set(CID=${IF($["${SP_E911CID}" != ""]?${SP_E911CID}:${SP_DEFAULT_E911_CID})})
same => n,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911)
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
same => n,Set(__SP_DIRECTION=outbound)
same => n,Set(CDR(SP_DIRECTION)=outbound)
same => n,Dial(PJSIP/911@trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37)
same => n,Hangup()
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1,150 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
[global]
type = global
user_agent = SelfPhoneNG
endpoint_identifier_order = ip,username,anonymous
keep_alive_interval = 90
max_initial_qualify_time = 4
[system]
type = system
timer_t1 = 500
timer_b = 32000
[transport-udp]
type = transport
protocol = udp
bind = 0.0.0.0:5060
[transport-tcp]
type = transport
protocol = tcp
bind = 0.0.0.0:5060
[transport-ws]
type = transport
protocol = ws
bind = 0.0.0.0
; ── extension 101 (Dan Doe) ──
[101]
type = endpoint
context = sp-from-ext
disallow = all
allow = g722,ulaw,alaw
auth = 101-auth
aors = 101
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
send_pai = yes
callerid = "Dan Doe" <101>
mailboxes = 101@default
mwi_subscribe_replaces_unsolicited = yes
dtmf_mode = rfc4733
allow_subscribe = yes
language = en
moh_suggest = default
tone_zone = us
set_var = SP_EXT=101
set_var = SP_CLASS=2
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal
[101-auth]
type = auth
auth_type = userpass
username = 101
password = Ab3dEf9hIj2kLm4nOp6qRs8t
[101]
type = aor
max_contacts = 5
remove_existing = yes
qualify_frequency = 30
mailboxes = 101@default
[101-web]
type = endpoint
context = sp-from-ext
webrtc = yes
disallow = all
allow = g722,ulaw
auth = 101-web-auth
aors = 101-web
from_domain = selfphoneng.invalid
callerid = "Dan Doe" <101>
mailboxes = 101@default
mwi_subscribe_replaces_unsolicited = yes
dtmf_mode = rfc4733
allow_subscribe = yes
language = en
moh_suggest = default
tone_zone = us
set_var = SP_EXT=101
set_var = SP_CLASS=2
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal
[101-web-auth]
type = auth
auth_type = userpass
username = 101-web
password = Ab3dEf9hIj2kLm4nOp6qRs8t
[101-web]
type = aor
max_contacts = 3
remove_existing = yes
qualify_frequency = 0
; ── trunk "voip.ms" ──
[trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth]
type = auth
auth_type = userpass
username = 123456_pbx
password = TrunkPassw0rd
[trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-reg]
type = registration
transport = transport-udp
outbound_auth = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth
server_uri = sip:chicago1.voip.ms:5060
client_uri = sip:123456_pbx@chicago1.voip.ms:5060
contact_user = 123456_pbx
retry_interval = 60
forbidden_retry_interval = 600
fatal_retry_interval = 600
max_retries = 10000
expiration = 300
line = yes
endpoint = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
[trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37]
type = aor
contact = sip:chicago1.voip.ms:5060
qualify_frequency = 60
[trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37]
type = endpoint
transport = transport-udp
context = sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
disallow = all
allow = ulaw
outbound_auth = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth
aors = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
from_user = 123456_pbx
from_domain = chicago1.voip.ms
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
send_rpid = yes
trust_id_inbound = yes
dtmf_mode = rfc4733
[trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-identify]
type = identify
endpoint = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
match = chicago1.voip.ms
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1,2 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
icesupport = yes
@@ -0,0 +1,23 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
[general]
format = wav
attach = no ; the api sends emails itself
maxmsg = 100
maxsecs = 300
minsecs = 1
maxsilence = 10
silencethreshold = 128
maxlogins = 3
envelope = yes
saycid = yes
review = no
nextaftercmd = yes
sendvoicemail = no
pollmailboxes = yes
pollfreq = 30
[zonemessages]
local = America/Chicago|'vm-received' Q 'digits/at' IMp
[default]
101 => 4826,Dan Doe,,,tz=local|attach=no
@@ -0,0 +1,36 @@
{
"extensions": [
{
"id": "00000000-0000-4000-8000-000000000101",
"number": "101",
"name": "Dan Doe",
"sipPassword": "Ab3dEf9hIj2kLm4nOp6qRs8t"
}
],
"conferences": [
{ "id": "55555555-5555-4555-8555-555555555800", "number": "800", "name": "Open room" },
{
"id": "55555555-5555-4555-8555-555555555801",
"number": "801",
"name": "Board; \"private\"",
"pin": "4826",
"adminPin": "917355",
"maxMembers": 8,
"record": true
},
{
"id": "55555555-5555-4555-8555-555555555802",
"number": "802",
"name": "Team",
"pin": "1357"
}
],
"dids": [
{
"id": "11111111-1111-4111-8111-111111111111",
"number": "13125551234",
"destination": { "type": "conference", "id": "55555555-5555-4555-8555-555555555801" }
}
],
"trunks": [{ "id": "7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37" }]
}
@@ -1 +1,6 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
[sp_admin_user]
type = user
admin = yes
marked = yes
music_on_hold_when_empty = yes
@@ -1 +1,6 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
[sp_admin_user]
type = user
admin = yes
marked = yes
music_on_hold_when_empty = yes
@@ -1 +1,6 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
[sp_admin_user]
type = user
admin = yes
marked = yes
music_on_hold_when_empty = yes
@@ -1 +1,6 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
[sp_admin_user]
type = user
admin = yes
marked = yes
music_on_hold_when_empty = yes
@@ -1 +1,6 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
[sp_admin_user]
type = user
admin = yes
marked = yes
music_on_hold_when_empty = yes
@@ -1 +1,6 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
[sp_admin_user]
type = user
admin = yes
marked = yes
music_on_hold_when_empty = yes
@@ -1 +1,6 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
[sp_admin_user]
type = user
admin = yes
marked = yes
music_on_hold_when_empty = yes
@@ -1 +1,6 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
[sp_admin_user]
type = user
admin = yes
marked = yes
music_on_hold_when_empty = yes
@@ -1 +1,6 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
[sp_admin_user]
type = user
admin = yes
marked = yes
music_on_hold_when_empty = yes
@@ -1 +1,6 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
[sp_admin_user]
type = user
admin = yes
marked = yes
music_on_hold_when_empty = yes
@@ -1 +1,6 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
[sp_admin_user]
type = user
admin = yes
marked = yes
music_on_hold_when_empty = yes
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Conference rooms (03 §6.11): sp-conf-{id} asks for the PIN (3 tries) when the room has one; the
// admin PIN joins with sp_admin_user. Each room has its own bridge profile (max members):
// CONFBRIDGE(bridge,max_members) is ignored once ConfBridge() names a profile (seen in M8-T3). A recorded room records each caller's leg like a recorded
// call (from the moment they're in), so it lands in call history and Recordings.
// ponytail: one file per participant; a single file per meeting (record_conference) needs its
// own recordings row and retention.
import { byNumber, type Snapshot } from '../load.ts';
import * as s from '../sanitize.ts';
const bridgeProfile = (number: string) => `sp_room_${s.digits(number)}`;
/** confbridge_selfphoneng.conf: the admin user profile and one bridge profile per room. */
export function renderConfbridge(snap: Snapshot): string {
return [
'[sp_admin_user]',
'type = user',
'admin = yes',
'marked = yes',
'music_on_hold_when_empty = yes',
...[...snap.conferences]
.sort(byNumber)
.flatMap((c) => [
'',
`[${bridgeProfile(c.number)}](default_bridge) ; room ${s.digits(c.number)}`,
`max_members = ${s.digits(String(c.maxMembers))}`,
]),
'',
].join('\n');
}
export function renderConferences(snap: Snapshot): string {
return [...snap.conferences]
.sort(byNumber)
.map((c) => {
const num = s.digits(c.number);
/** Lines that put the caller in the room; `label` names the first one. */
const enter = (label: string, profile: string) =>
[
...(c.record ? ['Gosub(sp-sub-record,s,1)'] : []),
`ConfBridge(${num},${bridgeProfile(num)},${profile},default_menu)`,
'Hangup()',
].map((app, i) => ` same => n${i === 0 ? label : ''},${app}`);
const lines = [
`; ─── Conference room ${num} "${s.name(c.name)}"`,
`[sp-conf-${s.ident(c.id)}]`,
`exten => s,1,Set(__SP_TARGET=${num})`,
' same => n,Answer()',
];
if (!c.pin) lines.push(...enter('', 'default_user'));
else
lines.push(
' same => n,Set(TRIES=0)',
' same => n(ask),Read(PIN,conf-getpin,10,,1,8)',
...(c.adminPin ? [` same => n,GotoIf($["\${PIN}" = "${s.pin(c.adminPin)}"]?admin)`] : []),
` same => n,GotoIf($["\${PIN}" = "${s.pin(c.pin)}"]?user)`,
' same => n,Set(TRIES=$[${TRIES} + 1])',
' same => n,GotoIf($[${TRIES} < 3]?bad)',
' same => n,Hangup()',
' same => n(bad),Playback(conf-invalidpin)',
' same => n,Goto(ask)',
...(c.adminPin ? enter('(admin)', 'sp_admin_user') : []),
...enter('(user)', 'default_user'),
);
return lines.join('\n') + '\n';
})
.join('\n');
}
+4 -1
View File
@@ -25,6 +25,10 @@ export function destApp(d: Destination, snap: Snapshot): string {
return snap.queues.some((q) => q.id === d.id)
? `Goto(sp-queue-${s.ident(d.id)},s,1)`
: 'Hangup()';
case 'conference':
return snap.conferences.some((c) => c.id === d.id)
? `Goto(sp-conf-${s.ident(d.id)},s,1)`
: 'Hangup()';
case 'ivr':
return snap.ivrs.some((r) => r.id === d.id)
? `Goto(sp-ivr-${s.ident(d.id)},s,1)`
@@ -38,7 +42,6 @@ export function destApp(d: Destination, snap: Snapshot): string {
? `Goto(sp-ann-${s.ident(d.id)},s,1)`
: 'Hangup()';
default:
// TODO(M8): conferences.
return 'Hangup()';
}
}
+4 -1
View File
@@ -27,10 +27,13 @@ export function renderLocalNumbers(snap: Snapshot): string {
if (snap.queues.length) lines.push('', '; queues');
for (const q of [...snap.queues].sort(byNumber))
lines.push(`exten => ${s.digits(q.number)},1,Goto(sp-queue-${s.ident(q.id)},s,1)`);
if (snap.conferences.length) lines.push('', '; conference rooms');
for (const c of [...snap.conferences].sort(byNumber))
lines.push(`exten => ${s.digits(c.number)},1,Goto(sp-conf-${s.ident(c.id)},s,1)`);
if (snap.ivrs.length) lines.push('', '; digital receptionists');
for (const r of [...snap.ivrs].sort(byNumber))
lines.push(`exten => ${s.digits(r.number)},1,Goto(sp-ivr-${s.ident(r.id)},s,1)`);
// M8: conferences and paging groups add one line each here.
// M8-T4: paging groups add one line each here.
return lines.join('\n') + '\n';
}
+11
View File
@@ -19,6 +19,7 @@ export function loadCase(name: string): Snapshot {
routes?: Partial<Snapshot['routes'][number]>[];
ringGroups?: Partial<Snapshot['ringGroups'][number]>[];
queues?: Partial<Snapshot['queues'][number]>[];
conferences?: Partial<Snapshot['conferences'][number]>[];
ivrs?: Partial<Snapshot['ivrs'][number]>[];
timeConditions?: Partial<Snapshot['timeConditions'][number]>[];
holidays?: Snapshot['holidays'];
@@ -104,6 +105,16 @@ export function loadCase(name: string): Snapshot {
id: q.id!,
number: q.number!,
})),
conferences: (raw.conferences ?? []).map((c, i) => ({
name: `Room ${i + 1}`,
pin: null,
adminPin: null,
maxMembers: 20,
record: false,
...c,
id: c.id!,
number: c.number!,
})),
routes: (raw.routes ?? []).map((r, i) => ({
name: `Route ${i + 1}`,
position: i,
+1
View File
@@ -26,6 +26,7 @@ describe('golden', () => {
'holidays',
'ivr-business-hours',
'queue',
'conferences',
]),
);
});
+3
View File
@@ -20,6 +20,7 @@ import { renderEmergency } from './dialplan/emergency.ts';
import { renderRingGroups } from './dialplan/ringgroups.ts';
import { renderQueueDialplan } from './dialplan/queues.ts';
import { renderQueues } from './queues.ts';
import { renderConfbridge, renderConferences } from './dialplan/conferences.ts';
import { renderIvrs } from './dialplan/ivrs.ts';
import { renderTimeConditions } from './dialplan/timeconditions.ts';
import { renderAnnouncements } from './dialplan/announcements.ts';
@@ -52,12 +53,14 @@ const BODIES: Partial<Record<GeneratedFileName, (snap: Snapshot) => string>> = {
'extensions_globals_selfphoneng.conf': renderGlobals,
'voicemail_selfphoneng.conf': renderVoicemail,
'queues_selfphoneng.conf': renderQueues,
'confbridge_selfphoneng.conf': renderConfbridge,
'extensions_selfphoneng.conf': (snap) =>
[
STATIC_DIALPLAN,
renderLocalNumbers(snap),
renderRingGroups(snap),
renderQueueDialplan(snap),
renderConferences(snap),
renderIvrs(snap),
renderTimeConditions(snap),
renderAnnouncements(snap),
+22
View File
@@ -5,6 +5,7 @@ import { alias } from 'drizzle-orm/pg-core';
import type { Db } from '../db/client.ts';
import {
announcements,
conferences,
dids,
extensions,
holidays,
@@ -115,6 +116,16 @@ export type SnapQueue = {
timeoutDestination: Destination;
};
export type SnapConference = {
id: string;
number: string;
name: string;
pin: string | null; // decrypted
adminPin: string | null; // decrypted
maxMembers: number;
record: boolean;
};
export type SnapIvr = {
id: string;
number: string;
@@ -167,6 +178,7 @@ export type Snapshot = {
routes: SnapRoute[];
ringGroups: SnapRingGroup[];
queues: SnapQueue[];
conferences: SnapConference[];
ivrs: SnapIvr[];
timeConditions: SnapTimeCondition[];
holidays: SnapHoliday[];
@@ -220,6 +232,7 @@ export async function loadSnapshot(db: Db, now = new Date()): Promise<Snapshot>
})
.from(queueMembers)
.innerJoin(extensions, eq(extensions.id, queueMembers.extensionId));
const confRows = await db.select().from(conferences);
const ivrRows = await db.select().from(ivrs);
const tcRows = await db.select().from(timeConditions);
const holidayRows = await db.select().from(holidays);
@@ -313,6 +326,15 @@ export async function loadSnapshot(db: Db, now = new Date()): Promise<Snapshot>
.map(({ number, name, penalty }) => ({ number, name, penalty })),
timeoutDestination: q.timeoutDestination as Destination,
})),
conferences: confRows.map((c) => ({
id: c.id,
number: c.number,
name: c.name,
pin: c.pinEnc ? decrypt(c.pinEnc) : null,
adminPin: c.adminPinEnc ? decrypt(c.adminPinEnc) : null,
maxMembers: c.maxMembers,
record: c.record,
})),
routes: routeRows.map((r) => ({
id: r.id,
name: r.name,
+148
View File
@@ -0,0 +1,148 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/conferences (04 §7.3): conference rooms. PINs are stored encrypted; admins read them back
// because they hand them out. Admin only.
import { asc, eq } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
ConferenceCreateSchema,
ConferenceUpdateSchema,
conferencePinsMakeSense,
type ConferenceDto,
type ListResponse,
} from '@selfphoneng/shared';
import { requireAdmin } from '../auth/guards.ts';
import { decrypt, encrypt } from '../crypto/secrets.ts';
import { conferences } from '../db/schema.ts';
import { badRequest, conflict, notFound } from '../errors.ts';
import { audit } from '../services/audit.ts';
import { findReferences } from '../services/destinations.ts';
import { assertNumberAvailable } from '../services/numbering.ts';
type Row = typeof conferences.$inferSelect;
const toDto = (c: Row): ConferenceDto => ({
id: c.id,
number: c.number,
name: c.name,
pin: c.pinEnc ? decrypt(c.pinEnc) : null,
adminPin: c.adminPinEnc ? decrypt(c.adminPinEnc) : null,
maxMembers: c.maxMembers,
record: c.record,
createdAt: c.createdAt.toISOString(),
});
/** PIN fields → encrypted columns (undefined = leave as is). */
function columns<T extends { pin?: string | null; adminPin?: string | null }>(body: T) {
const { pin, adminPin, ...rest } = body;
const enc = (v: string | null | undefined) =>
v === undefined ? undefined : v === null ? null : encrypt(v);
return { ...rest, pinEnc: enc(pin), adminPinEnc: enc(adminPin) };
}
/** For the audit log: whether PINs changed, never their values. */
const redact = (body: z.output<typeof ConferenceUpdateSchema>) => ({
...body,
...(body.pin !== undefined ? { pin: body.pin ? 'set' : 'none' } : {}),
...(body.adminPin !== undefined ? { adminPin: body.adminPin ? 'set' : 'none' } : {}),
});
const IdParams = z.object({ id: z.uuid() });
const isUniqueViolation = (e: unknown) =>
((e as { cause?: { code?: string } }).cause?.code ?? (e as { code?: string }).code) === '23505';
export async function conferenceRoutes(app: FastifyInstance) {
const { db } = app;
app.addHook('preHandler', requireAdmin);
async function getRoom(id: string) {
const [c] = await db.select().from(conferences).where(eq(conferences.id, id));
if (!c) throw notFound('Conference room not found');
return c;
}
app.get('/api/conferences', async (): Promise<ListResponse<ConferenceDto>> => {
const rows = await db.select().from(conferences).orderBy(asc(conferences.number));
return { items: rows.map(toDto), nextCursor: null };
});
app.get('/api/conferences/:id', async (req) =>
toDto(await getRoom(IdParams.parse(req.params).id)),
);
app.post('/api/conferences', async (req, reply) => {
const body = ConferenceCreateSchema.parse(req.body);
await assertNumberAvailable(db, body.number, 'conference');
let created: Row;
try {
[created] = (await db.insert(conferences).values(columns(body)).returning()) as [Row];
} catch (e) {
if (isUniqueViolation(e)) throw conflict(`${body.number} is already taken`);
throw e;
}
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'create',
entityType: 'conference',
entityId: created.id,
summary: `Created conference room ${created.number} (${created.name})`,
diff: { after: redact(body) },
});
app.generator.requestApply('api-change');
return reply.code(201).send(toDto(created));
});
app.patch('/api/conferences/:id', async (req) => {
const { id } = IdParams.parse(req.params);
const body = ConferenceUpdateSchema.parse(req.body);
const before = toDto(await getRoom(id));
if (!conferencePinsMakeSense({ ...before, ...body }))
throw badRequest('An admin PIN needs a (different) room PIN');
if (body.number && body.number !== before.number)
await assertNumberAvailable(db, body.number, 'conference', id);
let after: Row;
try {
[after] = (await db
.update(conferences)
.set(columns(body))
.where(eq(conferences.id, id))
.returning()) as [Row];
} catch (e) {
if (isUniqueViolation(e)) throw conflict(`${body.number} is already taken`);
throw e;
}
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'update',
entityType: 'conference',
entityId: id,
summary: `Updated conference room ${after.number} (${after.name})`,
diff: { after: redact(body) },
});
app.generator.requestApply('api-change');
return toDto(after);
});
app.delete('/api/conferences/:id', async (req) => {
const { id } = IdParams.parse(req.params);
const c = await getRoom(id);
const usedBy = await findReferences(db, 'conference', id);
if (usedBy.length)
throw conflict(`Conference room ${c.name} is still used by ${usedBy.join(', ')}`, {
usedBy,
});
await db.delete(conferences).where(eq(conferences.id, id));
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'delete',
entityType: 'conference',
entityId: id,
summary: `Deleted conference room ${c.number} (${c.name})`,
});
app.generator.requestApply('api-change');
return { ok: true };
});
}
+1
View File
@@ -66,6 +66,7 @@ function snapshot(): Snapshot {
routes: [],
ringGroups: [],
queues: [],
conferences: [],
ivrs: [],
timeConditions: [],
holidays: [],
+1
View File
@@ -155,6 +155,7 @@ function snapshot(): Snapshot {
? ({ type: 'hangup' } as const)
: ({ type: 'voicemail', id: CDR_IDS.ext('102') } as const),
})),
conferences: [],
ivrs: [
{
id: CDR_IDS.ivr,
+1
View File
@@ -105,6 +105,7 @@ function snapshot(): Snapshot {
trunks: [trunk(GOOD, 'voip.ms', SUB.pass), trunk(BAD, 'wrong password', 'NotThePassword1')],
ringGroups: [],
queues: [],
conferences: [],
ivrs: [],
timeConditions: [],
holidays: [],
+1
View File
@@ -61,6 +61,7 @@ function snapshot(): Snapshot {
routes: [],
ringGroups: [],
queues: [],
conferences: [],
ivrs: [],
timeConditions: [],
holidays: [],
+116
View File
@@ -0,0 +1,116 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M8-T3: /api/conferences (03 §6.11, 04 §7.3).
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { auditLog, conferences } from '../src/db/schema.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe.skipIf(!hasTestDb)('/api/conferences', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let req: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
let roomId: string;
const conf = async (f: string) => {
await t.app.generator.apply({ trigger: 'test' });
return readFile(join(t.genDir, f), 'utf8');
};
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
const cookie = await t.login(ADMIN.email, ADMIN.password);
req = (method, url, payload) =>
t.app.inject({
method: method as 'GET',
url,
payload: payload as object,
headers: { ...H, cookie },
});
});
afterAll(() => t?.close());
it('creates a room with PINs: stored encrypted, shown to admins, in the dialplan', async () => {
const r = await req('POST', '/api/conferences', {
number: '800',
name: 'Board',
pin: '4826',
adminPin: '917355',
});
expect(r.statusCode, r.body).toBe(201);
roomId = r.json().id;
expect(r.json()).toMatchObject({
pin: '4826',
adminPin: '917355',
maxMembers: 20,
record: false,
});
const [row] = await t.db.select().from(conferences);
expect(row!.pinEnc).not.toContain('4826');
const d = await conf('extensions_selfphoneng.conf');
expect(d).toContain(`exten => 800,1,Goto(sp-conf-${roomId},s,1)`);
expect(d).toContain(' same => n,GotoIf($["${PIN}" = "917355"]?admin)');
expect(d).toContain(' same => n(admin),ConfBridge(800,sp_room_800,sp_admin_user,default_menu)');
expect(await conf('confbridge_selfphoneng.conf')).toContain('[sp_admin_user]');
// the audit log says a PIN was set, not which
const logged = JSON.stringify(await t.db.select().from(auditLog));
expect(logged).not.toContain('4826');
expect(logged).not.toContain('917355');
});
it('checks the number and the PINs', async () => {
const bad = (body: object) =>
req('POST', '/api/conferences', { number: '801', name: 'X', ...body });
expect((await bad({ number: '700' })).statusCode).toBe(400); // parking, not 800–849
expect((await bad({ number: '800' })).statusCode).toBe(409);
expect((await bad({ pin: '12' })).statusCode).toBe(400);
expect((await bad({ pin: '12a4' })).statusCode).toBe(400);
expect((await bad({ adminPin: '1234' })).statusCode).toBe(400); // admin PIN alone
expect((await bad({ pin: '1234', adminPin: '1234' })).statusCode).toBe(400);
expect((await bad({ maxMembers: 1 })).statusCode).toBe(400);
// PATCH checks against what's stored: removing the room PIN keeps an admin PIN alone
expect((await req('PATCH', `/api/conferences/${roomId}`, { pin: null })).statusCode).toBe(400);
});
it('no PIN: straight in; record: each caller is recorded once inside', async () => {
const r = await req('PATCH', `/api/conferences/${roomId}`, {
pin: null,
adminPin: null,
record: true,
maxMembers: 5,
});
expect(r.statusCode, r.body).toBe(200);
expect(r.json()).toMatchObject({ pin: null, adminPin: null });
const d = await conf('extensions_selfphoneng.conf');
const ctx = d.slice(d.indexOf(`[sp-conf-${roomId}]`)).split('\n\n')[0]!;
expect(ctx).not.toContain('Read(PIN');
expect(await conf('confbridge_selfphoneng.conf')).toContain(
'[sp_room_800](default_bridge) ; room 800\nmax_members = 5',
);
expect(ctx).toContain(
'Gosub(sp-sub-record,s,1)\n same => n,ConfBridge(800,sp_room_800,default_user',
);
});
it('a phone number can point at it; delete is refused while it does', async () => {
const trunkId = (
await req('POST', '/api/trunks', {
name: 'voip.ms',
serverHost: '127.0.0.1',
username: '123456_pbx',
password: 'pw',
})
).json().id;
const did = await req('POST', '/api/dids', {
number: '3125551234',
trunkId,
destination: { type: 'conference', id: roomId },
});
expect(did.json().destinationSummary).toBe('Conference Board (800)');
expect((await req('DELETE', `/api/conferences/${roomId}`)).statusCode).toBe(409);
await req('PATCH', `/api/dids/${did.json().id}`, { destination: { type: 'hangup' } });
expect((await req('DELETE', `/api/conferences/${roomId}`)).statusCode).toBe(200);
expect(await conf('extensions_selfphoneng.conf')).not.toContain('sp-conf-');
});
});
+11
View File
@@ -31,6 +31,9 @@ import type {
OutboundRouteCreate,
OutboundRouteDto,
OutboundRouteUpdate,
ConferenceCreate,
ConferenceDto,
ConferenceUpdate,
QueueCreate,
QueueDto,
QueueStats,
@@ -250,6 +253,14 @@ export const queuesApi = {
api<{ ok: true }>('POST', `/api/queues/${id}/members/${extId}/${paused ? 'pause' : 'unpause'}`),
};
export const conferencesApi = {
list: () => api<ListResponse<ConferenceDto>>('GET', '/api/conferences').then((r) => r.items),
create: (c: ConferenceCreate) => api<ConferenceDto>('POST', '/api/conferences', c),
update: (id: string, c: ConferenceUpdate) =>
api<ConferenceDto>('PATCH', `/api/conferences/${id}`, c),
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/conferences/${id}`),
};
export const ivrsApi = {
list: () => api<ListResponse<IvrDto>>('GET', '/api/ivrs').then((r) => r.items),
create: (r: IvrCreate) => api<IvrDto>('POST', '/api/ivrs', r),
+14 -2
View File
@@ -1,11 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// "Where should the call go?" (05 §3): a type, then the target (or a number).
// Every type that exists (M6-T6, queues M8-T1); conferences join with M8-T3.
// Every type there is (M6-T6; queues M8-T1, conference rooms M8-T3).
import { useQuery } from '@tanstack/react-query';
import { useId, useState } from 'react';
import { normalizeNumber, type Destination } from '@selfphoneng/shared';
import {
announcementsApi,
conferencesApi,
extensionsApi,
ivrsApi,
queuesApi,
@@ -15,7 +16,14 @@ import {
import { inputClass } from './ui.tsx';
type IdKind =
'user' | 'voicemail' | 'ring_group' | 'queue' | 'ivr' | 'time_condition' | 'announcement';
| 'user'
| 'voicemail'
| 'ring_group'
| 'queue'
| 'ivr'
| 'time_condition'
| 'conference'
| 'announcement';
type Kind = IdKind | 'external' | 'hangup';
const KINDS: { kind: Kind; label: string }[] = [
@@ -25,6 +33,7 @@ const KINDS: { kind: Kind; label: string }[] = [
{ kind: 'queue', label: 'Queue' },
{ kind: 'ivr', label: 'Digital receptionist' },
{ kind: 'time_condition', label: 'Office hours' },
{ kind: 'conference', label: 'Conference room' },
{ kind: 'announcement', label: 'Announcement' },
{ kind: 'external', label: 'Forward to an outside number' },
{ kind: 'hangup', label: 'Hang up' },
@@ -37,6 +46,7 @@ const TARGET_NOUN: Record<IdKind, string> = {
queue: 'queue',
ivr: 'digital receptionist',
time_condition: 'office hours',
conference: 'conference room',
announcement: 'announcement',
};
const isIdKind = (k: Kind): k is IdKind => k !== 'external' && k !== 'hangup';
@@ -147,6 +157,7 @@ function useTargets() {
const ivrs = useQuery({ queryKey: ['ivrs'], queryFn: ivrsApi.list });
const tcs = useQuery({ queryKey: ['time-conditions'], queryFn: timeConditionsApi.list });
const anns = useQuery({ queryKey: ['announcements'], queryFn: announcementsApi.list });
const rooms = useQuery({ queryKey: ['conferences'], queryFn: conferencesApi.list });
const rows = (q: { data?: { id: string; name: string; number?: string }[] }) =>
q.data?.map((x) => ({ id: x.id, label: x.number ? `${x.number} ${x.name}` : x.name }));
return {
@@ -156,6 +167,7 @@ function useTargets() {
queue: rows(queues),
ivr: rows(ivrs),
time_condition: rows(tcs),
conference: rows(rooms),
announcement: rows(anns),
} satisfies Record<IdKind, unknown>;
}
+3
View File
@@ -17,6 +17,7 @@ import { SettingsPage } from './pages/admin/SettingsPage.tsx';
import { SoundsPage } from './pages/admin/SoundsPage.tsx';
import { RingGroupsPage } from './pages/admin/RingGroupsPage.tsx';
import { QueuesPage } from './pages/admin/QueuesPage.tsx';
import { ConferencesPage } from './pages/admin/ConferencesPage.tsx';
import { IvrsPage } from './pages/admin/IvrsPage.tsx';
import { OfficeHoursPage } from './pages/admin/OfficeHoursPage.tsx';
import { BlockedPage } from './pages/admin/BlockedPage.tsx';
@@ -55,6 +56,8 @@ const router = createBrowserRouter([
<RingGroupsPage />
) : p.path === 'queues' ? (
<QueuesPage />
) : p.path === 'conferences' ? (
<ConferencesPage />
) : p.path === 'ivrs' ? (
<IvrsPage />
) : p.path === 'hours' ? (
+1 -1
View File
@@ -19,7 +19,7 @@ export const ADMIN_PAGES = [
{ path: 'queues', label: 'Queues', milestone: null },
{ path: 'ivrs', label: 'Digital receptionist', milestone: null },
{ path: 'hours', label: 'Office hours', milestone: null },
{ path: 'conferences', label: 'Conferences', milestone: 'M8' },
{ path: 'conferences', label: 'Conferences', milestone: null },
{ path: 'paging', label: 'Paging', milestone: 'M8' },
{ path: 'sounds', label: 'Sounds & music', milestone: null },
{ path: 'devices', label: 'Phones', milestone: 'M10' },
@@ -0,0 +1,200 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Conference rooms (05 §2, 03 §6.11): number, name, PIN, admin PIN, max members, record.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import {
ConferenceCreateSchema,
type ConferenceCreate,
type ConferenceDto,
} from '@selfphoneng/shared';
import { conferencesApi, extensionsApi } from '../../api/client.ts';
import { ConfirmDialog, Dialog } from '../../components/Dialog.tsx';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
export function ConferencesPage() {
const qc = useQueryClient();
const rooms = useQuery({ queryKey: ['conferences'], queryFn: conferencesApi.list });
const [editing, setEditing] = useState<ConferenceDto | 'new' | null>(null);
const [deleting, setDeleting] = useState<ConferenceDto | null>(null);
const refresh = () => qc.invalidateQueries({ queryKey: ['conferences'] });
const remove = useMutation({
mutationFn: (id: string) => conferencesApi.remove(id),
onSuccess: async () => {
setDeleting(null);
await refresh();
},
});
return (
<section className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="text-xl font-semibold">Conference rooms</h1>
<Button variant="primary" onClick={() => setEditing('new')}>
Add room
</Button>
</div>
{rooms.isError && <Alert>{rooms.error.message}</Alert>}
{rooms.data?.length === 0 && (
<p className="text-sm text-zinc-500">
No conference rooms yet. A room is a number (like 800) that several people dial to talk
together, with an optional PIN.
</p>
)}
{rooms.data && rooms.data.length > 0 && (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
{rooms.data.map((c) => (
<li
key={c.id}
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
>
<span className="font-mono">{c.number}</span>
<span className="font-medium">{c.name}</span>
<span className="min-w-0 flex-1 truncate text-zinc-500">
{c.pin ? `PIN ${c.pin}` : 'No PIN'}
{c.adminPin ? ` · admin PIN ${c.adminPin}` : ''} · up to {c.maxMembers}
{c.record ? ' · recorded' : ''}
</span>
<Button onClick={() => setEditing(c)}>Edit</Button>
<Button variant="ghost" onClick={() => setDeleting(c)}>
Delete
</Button>
</li>
))}
</ul>
)}
{editing && (
<RoomForm
room={editing === 'new' ? null : editing}
onClose={() => setEditing(null)}
onSaved={refresh}
/>
)}
<ConfirmDialog
open={deleting !== null}
title="Delete conference room"
message={
<p>
Delete conference room {deleting?.number} ({deleting?.name})?
</p>
}
confirmLabel="Delete"
busy={remove.isPending}
error={remove.error?.message}
onCancel={() => {
setDeleting(null);
remove.reset();
}}
onConfirm={() => deleting && remove.mutate(deleting.id)}
/>
</section>
);
}
function RoomForm({
room,
onClose,
onSaved,
}: {
room: ConferenceDto | null;
onClose: () => void;
onSaved: () => Promise<unknown>;
}) {
const next = useQuery({
queryKey: ['next-number', 'conference'],
queryFn: () => extensionsApi.nextNumber('conference'),
enabled: !room,
});
const [form, setForm] = useState({
number: room?.number ?? '',
name: room?.name ?? '',
pin: room?.pin ?? '',
adminPin: room?.adminPin ?? '',
maxMembers: String(room?.maxMembers ?? 20),
record: room?.record ?? false,
});
const [errors, setErrors] = useState<Record<string, string>>({});
const number = form.number || (room ? '' : (next.data ?? ''));
const save = useMutation({
mutationFn: (body: ConferenceCreate) =>
room ? conferencesApi.update(room.id, body) : conferencesApi.create(body),
onSuccess: async () => {
await onSaved();
onClose();
},
});
function submit() {
const parsed = ConferenceCreateSchema.safeParse({
number,
name: form.name,
pin: form.pin.trim() || null,
adminPin: form.adminPin.trim() || null,
maxMembers: Number(form.maxMembers),
record: form.record,
});
if (!parsed.success) {
setErrors(Object.fromEntries(parsed.error.issues.map((i) => [String(i.path[0]), i.message])));
return;
}
setErrors({});
save.mutate(parsed.data);
}
const set = (k: keyof typeof form) => (v: string | boolean) => setForm((f) => ({ ...f, [k]: v }));
const text = (
k: 'number' | 'name' | 'pin' | 'adminPin' | 'maxMembers',
label: string,
hint?: string,
) => (
<Field label={label} error={errors[k]} hint={hint}>
{(p) => (
<input
{...p}
className={inputClass}
inputMode={k === 'name' ? undefined : 'numeric'}
value={k === 'number' ? number : form[k]}
onChange={(e) => set(k)(e.target.value)}
/>
)}
</Field>
);
return (
<Dialog
open
drawer
onClose={onClose}
title={room ? `Conference room ${room.number}` : 'New conference room'}
footer={
<>
<Button onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={submit} disabled={save.isPending}>
Save
</Button>
</>
}
>
<div className="flex flex-col gap-4">
{text('number', 'Number', 'People dial this to join')}
{text('name', 'Name')}
{text('pin', 'PIN', 'Optional, 4 to 10 digits. Callers enter it to join.')}
{text(
'adminPin',
'Admin PIN',
"Optional, needs a PIN too. Whoever enters it joins as the room's admin.",
)}
{text('maxMembers', 'Most people at once')}
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.record}
onChange={(e) => set('record')(e.target.checked)}
/>
Record (each caller's recording shows in Call history and Recordings)
</label>
{save.error && <Alert>{save.error.message}</Alert>}
</div>
</Dialog>
);
}