conferences: rooms with PIN and admin PIN, per-room limits, recording
/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:
1 parent
964a3c0b25
commit
a6c423de97
45 files changed
+1456
-14
No files matched your search
@@ -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
|
||||
+5
@@ -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
|
||||
+5
@@ -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');
|
||||
}
|
||||
@@ -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()';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -26,6 +26,7 @@ describe('golden', () => {
|
||||
'holidays',
|
||||
'ivr-business-hours',
|
||||
'queue',
|
||||
'conferences',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -66,6 +66,7 @@ function snapshot(): Snapshot {
|
||||
routes: [],
|
||||
ringGroups: [],
|
||||
queues: [],
|
||||
conferences: [],
|
||||
ivrs: [],
|
||||
timeConditions: [],
|
||||
holidays: [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -61,6 +61,7 @@ function snapshot(): Snapshot {
|
||||
routes: [],
|
||||
ringGroups: [],
|
||||
queues: [],
|
||||
conferences: [],
|
||||
ivrs: [],
|
||||
timeConditions: [],
|
||||
holidays: [],
|
||||
|
||||
@@ -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-');
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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' ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user