timeconditions: office hours, holidays, override codes, weekly grid
/api/time-conditions (company time zone by default, ranges validated,
unique override digit, never pointing at itself) with a live status
("open/closed/holiday now", same logic as the dialplan, shared), and
POST /:id/override, which writes PostgreSQL then AstDB SP/TCOVR.
/api/holidays CRUD plus import-us-federal (observed dates, computed in
code, skips days already listed). services/astdb.ts resyncs AstDB on
every AMI connect (PostgreSQL wins); services/toggles.ts applies
SpToggle TCOVR from *31N/*32N/*33N.
Generator: sp-tc-{id} per 03 §6.10 (override, holidays within the next
year, ranges rendered a minute short because GotoIfTime's end is
inclusive), sp-tc-override with a no-service fallback, admin-only
*3[123]X in sp-featurecodes, destApp time_condition. The date changing
in the company time zone triggers a forced apply so holidays roll over.
Golden cases holidays and ivr-business-hours.
Office hours page: click-and-drag half-hour grid plus an exact range
list, destinations, holiday destination, override key, status chip with
Force open / Force closed / Automatic; Holidays tab with the US federal
import. e2e m6-t4 found two bugs (pointer capture release throwing,
drawer overflow) and checks routing, AstDB, *321/*331 from an admin
phone, refusal for others, and the import reaching the dialplan.
Task: M6-T4
This commit is contained in:
1 parent
15624f7b77
commit
108547046c
57 files changed
+3221
-29
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 { ivrRoutes } from './routes/ivrs.ts';
|
||||
import { timeConditionRoutes } from './routes/timeconditions.ts';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
@@ -99,6 +100,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
|
||||
app.register(soundRoutes);
|
||||
app.register(ringGroupRoutes);
|
||||
app.register(ivrRoutes);
|
||||
app.register(timeConditionRoutes);
|
||||
|
||||
// Always registered: reply.sendFile also streams voicemail audio (with Range) from the spool.
|
||||
app.register(fastifyStatic, {
|
||||
|
||||
@@ -85,7 +85,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Feature codes (03 §6.13). M4: voicemail only; M6-T7 adds the rest.
|
||||
; ─── Feature codes (03 §6.13). M4: voicemail; M6-T4: office hours overrides; M6-T7 adds the rest.
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
@@ -93,6 +93,12 @@ exten => *97,1,Answer() ; my voicemail
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,VoiceMailMain(@default)
|
||||
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()
|
||||
@@ -124,6 +130,11 @@ include => sp-parkedcalls
|
||||
|
||||
; users (sorted by number)
|
||||
|
||||
; ─── Office hours overrides (admins only, see sp-featurecodes)
|
||||
[sp-tc-override]
|
||||
exten => _*3[123]X,1,Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-inbound]
|
||||
; anything else
|
||||
exten => _[0-9a-z].,1,Hangup()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_CLASS_101=2
|
||||
SP_TCADMIN_101=1
|
||||
@@ -0,0 +1,224 @@
|
||||
; 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,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). M4: voicemail; M6-T4: office hours overrides; M6-T7 adds the rest.
|
||||
[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()
|
||||
; 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
|
||||
|
||||
; ─── Office hours "Main office"
|
||||
[sp-tc-55555555-5555-4555-8555-555555555001]
|
||||
exten => s,1,Set(OVR=${DB(SP/TCOVR/55555555-5555-4555-8555-555555555001)})
|
||||
same => n,GotoIf($["${OVR}" = "open"]?open)
|
||||
same => n,GotoIf($["${OVR}" = "closed"]?closed)
|
||||
same => n,GotoIfTime(*,*,1,jan,America/Chicago?holiday)
|
||||
same => n,GotoIfTime(*,*,2,jan,America/Chicago?holiday)
|
||||
same => n,GotoIfTime(*,*,26,nov,America/Chicago?holiday)
|
||||
same => n,GotoIfTime(*,*,24,dec,America/Chicago?holiday)
|
||||
same => n,GotoIfTime(*,*,25,dec,America/Chicago?holiday)
|
||||
same => n,GotoIfTime(*,*,26,dec,America/Chicago?holiday)
|
||||
same => n,GotoIfTime(*,*,31,dec,America/Chicago?holiday)
|
||||
same => n,GotoIfTime(08:00-16:59,mon&tue&wed&thu&fri,*,*,America/Chicago?open)
|
||||
same => n,GotoIfTime(09:00-12:29,sat,*,*,America/Chicago?open)
|
||||
same => n,GotoIfTime(20:00-23:59,sun,*,*,America/Chicago?open)
|
||||
same => n,Goto(closed)
|
||||
same => n(open),Goto(sp-local-numbers,101,1)
|
||||
same => n(closed),Goto(sp-vm-direct,101,1)
|
||||
same => n(holiday),Hangup()
|
||||
|
||||
; ─── Office hours "Warehouse no holidays"
|
||||
[sp-tc-55555555-5555-4555-8555-555555555002]
|
||||
exten => s,1,Set(OVR=${DB(SP/TCOVR/55555555-5555-4555-8555-555555555002)})
|
||||
same => n,GotoIf($["${OVR}" = "open"]?open)
|
||||
same => n,GotoIf($["${OVR}" = "closed"]?closed)
|
||||
same => n,GotoIfTime(06:00-13:59,mon,*,*,America/New_York?open)
|
||||
same => n,Goto(closed)
|
||||
same => n(open),Goto(sp-local-numbers,101,1)
|
||||
same => n(closed),Hangup()
|
||||
same => n(holiday),Hangup()
|
||||
|
||||
; ─── Office hours overrides (admins only, see sp-featurecodes)
|
||||
[sp-tc-override]
|
||||
exten => *311,1,Set(DB(SP/TCOVR/55555555-5555-4555-8555-555555555001)=open)
|
||||
same => n,UserEvent(SpToggle,Key: TCOVR,Id: 55555555-5555-4555-8555-555555555001,Value: open)
|
||||
same => n,Playback(activated)
|
||||
same => n,Hangup()
|
||||
exten => *321,1,Set(DB(SP/TCOVR/55555555-5555-4555-8555-555555555001)=closed)
|
||||
same => n,UserEvent(SpToggle,Key: TCOVR,Id: 55555555-5555-4555-8555-555555555001,Value: closed)
|
||||
same => n,Playback(activated)
|
||||
same => n,Hangup()
|
||||
exten => *331,1,Set(X=${DB_DELETE(SP/TCOVR/55555555-5555-4555-8555-555555555001)})
|
||||
same => n,UserEvent(SpToggle,Key: TCOVR,Id: 55555555-5555-4555-8555-555555555001,Value: auto)
|
||||
same => n,Playback(de-activated)
|
||||
same => n,Hangup()
|
||||
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,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,Goto(sp-tc-55555555-5555-4555-8555-555555555001,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,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,82 @@
|
||||
{
|
||||
"today": "2026-10-17",
|
||||
"extensions": [
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000101",
|
||||
"number": "101",
|
||||
"name": "Dan Doe",
|
||||
"sipPassword": "Ab3dEf9hIj2kLm4nOp6qRs8t",
|
||||
"isAdmin": true
|
||||
}
|
||||
],
|
||||
"timeConditions": [
|
||||
{
|
||||
"id": "55555555-5555-4555-8555-555555555001",
|
||||
"name": "Main office",
|
||||
"openRanges": [
|
||||
{ "days": ["mon", "tue", "wed", "thu", "fri"], "start": "08:00", "end": "17:00" },
|
||||
{ "days": ["sat"], "start": "09:00", "end": "12:30" },
|
||||
{ "days": ["sun"], "start": "20:00", "end": "24:00" }
|
||||
],
|
||||
"overrideDigit": 1,
|
||||
"openDestination": { "type": "user", "id": "00000000-0000-4000-8000-000000000101" },
|
||||
"closedDestination": { "type": "voicemail", "id": "00000000-0000-4000-8000-000000000101" },
|
||||
"holidayDestination": { "type": "hangup" }
|
||||
},
|
||||
{
|
||||
"id": "55555555-5555-4555-8555-555555555002",
|
||||
"name": "Warehouse (no holidays)",
|
||||
"timezone": "America/New_York",
|
||||
"useHolidays": false,
|
||||
"openRanges": [{ "days": ["mon"], "start": "06:00", "end": "14:00" }],
|
||||
"openDestination": { "type": "user", "id": "00000000-0000-4000-8000-000000000101" },
|
||||
"closedDestination": { "type": "hangup" }
|
||||
}
|
||||
],
|
||||
"holidays": [
|
||||
{
|
||||
"name": "Christmas Day",
|
||||
"startDate": "2020-12-25",
|
||||
"endDate": "2020-12-25",
|
||||
"recurringYearly": true
|
||||
},
|
||||
{
|
||||
"name": "Thanksgiving Day",
|
||||
"startDate": "2026-11-26",
|
||||
"endDate": "2026-11-26",
|
||||
"recurringYearly": false
|
||||
},
|
||||
{
|
||||
"name": "Independence Day (observed), past",
|
||||
"startDate": "2026-07-03",
|
||||
"endDate": "2026-07-03",
|
||||
"recurringYearly": false
|
||||
},
|
||||
{
|
||||
"name": "Christmas week",
|
||||
"startDate": "2026-12-24",
|
||||
"endDate": "2026-12-26",
|
||||
"recurringYearly": false
|
||||
},
|
||||
{
|
||||
"name": "New Year break",
|
||||
"startDate": "2026-12-31",
|
||||
"endDate": "2027-01-02",
|
||||
"recurringYearly": false
|
||||
},
|
||||
{
|
||||
"name": "Too far ahead",
|
||||
"startDate": "2027-12-31",
|
||||
"endDate": "2027-12-31",
|
||||
"recurringYearly": false
|
||||
}
|
||||
],
|
||||
"dids": [
|
||||
{
|
||||
"id": "11111111-1111-4111-8111-111111111111",
|
||||
"number": "13125551234",
|
||||
"destination": { "type": "time_condition", "id": "55555555-5555-4555-8555-555555555001" }
|
||||
}
|
||||
],
|
||||
"trunks": [{ "id": "7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37" }]
|
||||
}
|
||||
+12
-1
@@ -85,7 +85,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Feature codes (03 §6.13). M4: voicemail only; M6-T7 adds the rest.
|
||||
; ─── Feature codes (03 §6.13). M4: voicemail; M6-T4: office hours overrides; M6-T7 adds the rest.
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
@@ -93,6 +93,12 @@ exten => *97,1,Answer() ; my voicemail
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,VoiceMailMain(@default)
|
||||
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()
|
||||
@@ -133,6 +139,11 @@ exten => 105,1,Gosub(sp-sub-user,s,1(105,25,1,never))
|
||||
same => n,Hangup()
|
||||
exten => 105,hint,PJSIP/105&PJSIP/105-web
|
||||
|
||||
; ─── Office hours overrides (admins only, see sp-featurecodes)
|
||||
[sp-tc-override]
|
||||
exten => _*3[123]X,1,Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-inbound]
|
||||
; anything else
|
||||
exten => _[0-9a-z].,1,Hangup()
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_CLASS_101=2
|
||||
SP_CLASS_102=2
|
||||
SP_CLASS_103=2
|
||||
SP_TCADMIN_101=1
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
; 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,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). M4: voicemail; M6-T4: office hours overrides; M6-T7 adds the rest.
|
||||
[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()
|
||||
; 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
|
||||
exten => 102,1,Gosub(sp-sub-user,s,1(102,25,1,never))
|
||||
same => n,Hangup()
|
||||
exten => 102,hint,PJSIP/102&PJSIP/102-web
|
||||
exten => 103,1,Gosub(sp-sub-user,s,1(103,25,1,never))
|
||||
same => n,Hangup()
|
||||
exten => 103,hint,PJSIP/103&PJSIP/103-web
|
||||
|
||||
; ring groups
|
||||
exten => 500,1,Goto(sp-rg-22222222-2222-4222-8222-222222222500,s,1)
|
||||
|
||||
; digital receptionists
|
||||
exten => 600,1,Goto(sp-ivr-33333333-3333-4333-8333-333333333600,s,1)
|
||||
|
||||
; ─── Ring group 500 "Sales" (ringall)
|
||||
[sp-rg-22222222-2222-4222-8222-222222222500]
|
||||
exten => s,1,Set(__SP_TARGET=500)
|
||||
same => n,Dial(Local/101@sp-rg-member&Local/103@sp-rg-member,25,t)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done)
|
||||
same => n,Goto(sp-vm-direct,101,1)
|
||||
same => n(done),Hangup()
|
||||
|
||||
; ─── Digital receptionist 600 "Main menu"
|
||||
[sp-ivr-33333333-3333-4333-8333-333333333600]
|
||||
include => sp-local-numbers
|
||||
exten => s,1,Answer()
|
||||
same => n,Set(SP_IVR_TRIES=0)
|
||||
same => n(menu),Set(TIMEOUT(digit)=3)
|
||||
same => n,Background(custom/44444444-4444-4444-8444-444444444444)
|
||||
same => n,WaitExten(5)
|
||||
exten => 1,1,Goto(sp-rg-22222222-2222-4222-8222-222222222500,s,1)
|
||||
exten => 2,1,Goto(sp-local-numbers,102,1)
|
||||
exten => i,1,Set(SP_IVR_TRIES=$[${SP_IVR_TRIES} + 1])
|
||||
same => n,GotoIf($[${SP_IVR_TRIES} >= 3]?giveup)
|
||||
same => n,Playback(pbx-invalid)
|
||||
same => n,Goto(s,menu)
|
||||
same => n(giveup),Goto(sp-vm-direct,101,1)
|
||||
exten => t,1,Set(SP_IVR_TRIES=$[${SP_IVR_TRIES} + 1])
|
||||
same => n,GotoIf($[${SP_IVR_TRIES} >= 3]?giveup)
|
||||
same => n,Goto(s,menu)
|
||||
same => n(giveup),Goto(sp-rg-22222222-2222-4222-8222-222222222500,s,1)
|
||||
|
||||
; ─── Office hours "Business hours"
|
||||
[sp-tc-55555555-5555-4555-8555-555555555001]
|
||||
exten => s,1,Set(OVR=${DB(SP/TCOVR/55555555-5555-4555-8555-555555555001)})
|
||||
same => n,GotoIf($["${OVR}" = "open"]?open)
|
||||
same => n,GotoIf($["${OVR}" = "closed"]?closed)
|
||||
same => n,GotoIfTime(*,*,26,nov,America/Chicago?holiday)
|
||||
same => n,GotoIfTime(08:00-16:59,mon&tue&wed&thu&fri,*,*,America/Chicago?open)
|
||||
same => n,Goto(closed)
|
||||
same => n(open),Goto(sp-ivr-33333333-3333-4333-8333-333333333600,s,1)
|
||||
same => n(closed),Goto(sp-vm-direct,101,1)
|
||||
same => n(holiday),Goto(sp-vm-direct,101,1)
|
||||
|
||||
; ─── Office hours overrides (admins only, see sp-featurecodes)
|
||||
[sp-tc-override]
|
||||
exten => *311,1,Set(DB(SP/TCOVR/55555555-5555-4555-8555-555555555001)=open)
|
||||
same => n,UserEvent(SpToggle,Key: TCOVR,Id: 55555555-5555-4555-8555-555555555001,Value: open)
|
||||
same => n,Playback(activated)
|
||||
same => n,Hangup()
|
||||
exten => *321,1,Set(DB(SP/TCOVR/55555555-5555-4555-8555-555555555001)=closed)
|
||||
same => n,UserEvent(SpToggle,Key: TCOVR,Id: 55555555-5555-4555-8555-555555555001,Value: closed)
|
||||
same => n,Playback(activated)
|
||||
same => n,Hangup()
|
||||
exten => *331,1,Set(X=${DB_DELETE(SP/TCOVR/55555555-5555-4555-8555-555555555001)})
|
||||
same => n,UserEvent(SpToggle,Key: TCOVR,Id: 55555555-5555-4555-8555-555555555001,Value: auto)
|
||||
same => n,Playback(de-activated)
|
||||
same => n,Hangup()
|
||||
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,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,Goto(sp-tc-55555555-5555-4555-8555-555555555001,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,Dial(PJSIP/911@trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37)
|
||||
same => n,Hangup()
|
||||
+1
@@ -0,0 +1 @@
|
||||
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
|
||||
@@ -0,0 +1,296 @@
|
||||
; 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
|
||||
|
||||
; ── extension 102 (Sue Smith) ──
|
||||
[102]
|
||||
type = endpoint
|
||||
context = sp-from-ext
|
||||
disallow = all
|
||||
allow = g722,ulaw,alaw
|
||||
auth = 102-auth
|
||||
aors = 102
|
||||
direct_media = no
|
||||
rtp_symmetric = yes
|
||||
force_rport = yes
|
||||
rewrite_contact = yes
|
||||
send_pai = yes
|
||||
callerid = "Sue Smith" <102>
|
||||
mailboxes = 102@default
|
||||
mwi_subscribe_replaces_unsolicited = yes
|
||||
dtmf_mode = rfc4733
|
||||
allow_subscribe = yes
|
||||
language = en
|
||||
moh_suggest = default
|
||||
tone_zone = us
|
||||
set_var = SP_EXT=102
|
||||
set_var = SP_CLASS=2
|
||||
set_var = SP_RECMODE=never
|
||||
set_var = __SP_DIRECTION=internal
|
||||
|
||||
[102-auth]
|
||||
type = auth
|
||||
auth_type = userpass
|
||||
username = 102
|
||||
password = Bb3dEf9hIj2kLm4nOp6qRs8t
|
||||
|
||||
[102]
|
||||
type = aor
|
||||
max_contacts = 5
|
||||
remove_existing = yes
|
||||
qualify_frequency = 30
|
||||
mailboxes = 102@default
|
||||
|
||||
[102-web]
|
||||
type = endpoint
|
||||
context = sp-from-ext
|
||||
webrtc = yes
|
||||
disallow = all
|
||||
allow = g722,ulaw
|
||||
auth = 102-web-auth
|
||||
aors = 102-web
|
||||
from_domain = selfphoneng.invalid
|
||||
callerid = "Sue Smith" <102>
|
||||
mailboxes = 102@default
|
||||
mwi_subscribe_replaces_unsolicited = yes
|
||||
dtmf_mode = rfc4733
|
||||
allow_subscribe = yes
|
||||
language = en
|
||||
moh_suggest = default
|
||||
tone_zone = us
|
||||
set_var = SP_EXT=102
|
||||
set_var = SP_CLASS=2
|
||||
set_var = SP_RECMODE=never
|
||||
set_var = __SP_DIRECTION=internal
|
||||
|
||||
[102-web-auth]
|
||||
type = auth
|
||||
auth_type = userpass
|
||||
username = 102-web
|
||||
password = Bb3dEf9hIj2kLm4nOp6qRs8t
|
||||
|
||||
[102-web]
|
||||
type = aor
|
||||
max_contacts = 3
|
||||
remove_existing = yes
|
||||
qualify_frequency = 0
|
||||
|
||||
; ── extension 103 (Ann Lee) ──
|
||||
[103]
|
||||
type = endpoint
|
||||
context = sp-from-ext
|
||||
disallow = all
|
||||
allow = g722,ulaw,alaw
|
||||
auth = 103-auth
|
||||
aors = 103
|
||||
direct_media = no
|
||||
rtp_symmetric = yes
|
||||
force_rport = yes
|
||||
rewrite_contact = yes
|
||||
send_pai = yes
|
||||
callerid = "Ann Lee" <103>
|
||||
mailboxes = 103@default
|
||||
mwi_subscribe_replaces_unsolicited = yes
|
||||
dtmf_mode = rfc4733
|
||||
allow_subscribe = yes
|
||||
language = en
|
||||
moh_suggest = default
|
||||
tone_zone = us
|
||||
set_var = SP_EXT=103
|
||||
set_var = SP_CLASS=2
|
||||
set_var = SP_RECMODE=never
|
||||
set_var = __SP_DIRECTION=internal
|
||||
|
||||
[103-auth]
|
||||
type = auth
|
||||
auth_type = userpass
|
||||
username = 103
|
||||
password = Cb3dEf9hIj2kLm4nOp6qRs8t
|
||||
|
||||
[103]
|
||||
type = aor
|
||||
max_contacts = 5
|
||||
remove_existing = yes
|
||||
qualify_frequency = 30
|
||||
mailboxes = 103@default
|
||||
|
||||
[103-web]
|
||||
type = endpoint
|
||||
context = sp-from-ext
|
||||
webrtc = yes
|
||||
disallow = all
|
||||
allow = g722,ulaw
|
||||
auth = 103-web-auth
|
||||
aors = 103-web
|
||||
from_domain = selfphoneng.invalid
|
||||
callerid = "Ann Lee" <103>
|
||||
mailboxes = 103@default
|
||||
mwi_subscribe_replaces_unsolicited = yes
|
||||
dtmf_mode = rfc4733
|
||||
allow_subscribe = yes
|
||||
language = en
|
||||
moh_suggest = default
|
||||
tone_zone = us
|
||||
set_var = SP_EXT=103
|
||||
set_var = SP_CLASS=2
|
||||
set_var = SP_RECMODE=never
|
||||
set_var = __SP_DIRECTION=internal
|
||||
|
||||
[103-web-auth]
|
||||
type = auth
|
||||
auth_type = userpass
|
||||
username = 103-web
|
||||
password = Cb3dEf9hIj2kLm4nOp6qRs8t
|
||||
|
||||
[103-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
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
; 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
|
||||
102 => 4826,Sue Smith,,,tz=local|attach=no
|
||||
103 => 4826,Ann Lee,,,tz=local|attach=no
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"extensions": [
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000101",
|
||||
"number": "101",
|
||||
"name": "Dan Doe",
|
||||
"sipPassword": "Ab3dEf9hIj2kLm4nOp6qRs8t",
|
||||
"isAdmin": true
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000102",
|
||||
"number": "102",
|
||||
"name": "Sue Smith",
|
||||
"sipPassword": "Bb3dEf9hIj2kLm4nOp6qRs8t"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000103",
|
||||
"number": "103",
|
||||
"name": "Ann Lee",
|
||||
"sipPassword": "Cb3dEf9hIj2kLm4nOp6qRs8t"
|
||||
}
|
||||
],
|
||||
"ringGroups": [
|
||||
{
|
||||
"id": "22222222-2222-4222-8222-222222222500",
|
||||
"number": "500",
|
||||
"name": "Sales",
|
||||
"members": ["101", "103"],
|
||||
"noAnswerDestination": { "type": "voicemail", "id": "00000000-0000-4000-8000-000000000101" }
|
||||
}
|
||||
],
|
||||
"ivrs": [
|
||||
{
|
||||
"id": "33333333-3333-4333-8333-333333333600",
|
||||
"number": "600",
|
||||
"name": "Main menu",
|
||||
"greeting": "custom/44444444-4444-4444-8444-444444444444",
|
||||
"options": [
|
||||
{
|
||||
"digit": "1",
|
||||
"destination": { "type": "ring_group", "id": "22222222-2222-4222-8222-222222222500" }
|
||||
},
|
||||
{
|
||||
"digit": "2",
|
||||
"destination": { "type": "user", "id": "00000000-0000-4000-8000-000000000102" }
|
||||
}
|
||||
],
|
||||
"timeoutDestination": { "type": "ring_group", "id": "22222222-2222-4222-8222-222222222500" },
|
||||
"invalidDestination": { "type": "voicemail", "id": "00000000-0000-4000-8000-000000000101" }
|
||||
}
|
||||
],
|
||||
"timeConditions": [
|
||||
{
|
||||
"id": "55555555-5555-4555-8555-555555555001",
|
||||
"name": "Business hours",
|
||||
"openRanges": [
|
||||
{ "days": ["mon", "tue", "wed", "thu", "fri"], "start": "08:00", "end": "17:00" }
|
||||
],
|
||||
"overrideDigit": 1,
|
||||
"openDestination": { "type": "ivr", "id": "33333333-3333-4333-8333-333333333600" },
|
||||
"closedDestination": { "type": "voicemail", "id": "00000000-0000-4000-8000-000000000101" }
|
||||
}
|
||||
],
|
||||
"holidays": [
|
||||
{
|
||||
"name": "Thanksgiving Day",
|
||||
"startDate": "2026-11-26",
|
||||
"endDate": "2026-11-26",
|
||||
"recurringYearly": false
|
||||
}
|
||||
],
|
||||
"dids": [
|
||||
{
|
||||
"id": "11111111-1111-4111-8111-111111111111",
|
||||
"number": "13125551234",
|
||||
"destination": { "type": "time_condition", "id": "55555555-5555-4555-8555-555555555001" }
|
||||
}
|
||||
],
|
||||
"trunks": [{ "id": "7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37" }]
|
||||
}
|
||||
@@ -85,7 +85,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Feature codes (03 §6.13). M4: voicemail only; M6-T7 adds the rest.
|
||||
; ─── Feature codes (03 §6.13). M4: voicemail; M6-T4: office hours overrides; M6-T7 adds the rest.
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
@@ -93,6 +93,12 @@ exten => *97,1,Answer() ; my voicemail
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,VoiceMailMain(@default)
|
||||
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()
|
||||
@@ -185,6 +191,11 @@ exten => t,1,Set(SP_IVR_TRIES=$[${SP_IVR_TRIES} + 1])
|
||||
same => n,Goto(s,menu)
|
||||
same => n(giveup),Goto(sp-ivr-33333333-3333-4333-8333-333333333600,s,1)
|
||||
|
||||
; ─── 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)
|
||||
|
||||
@@ -85,7 +85,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Feature codes (03 §6.13). M4: voicemail only; M6-T7 adds the rest.
|
||||
; ─── Feature codes (03 §6.13). M4: voicemail; M6-T4: office hours overrides; M6-T7 adds the rest.
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
@@ -93,6 +93,12 @@ exten => *97,1,Answer() ; my voicemail
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,VoiceMailMain(@default)
|
||||
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()
|
||||
@@ -127,6 +133,11 @@ exten => 101,1,Gosub(sp-sub-user,s,1(101,25,1,never))
|
||||
same => n,Hangup()
|
||||
exten => 101,hint,PJSIP/101&PJSIP/101-web
|
||||
|
||||
; ─── Office hours overrides (admins only, see sp-featurecodes)
|
||||
[sp-tc-override]
|
||||
exten => _*3[123]X,1,Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-did-exists]
|
||||
exten => 13125551234,1,NoOp()
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Feature codes (03 §6.13). M4: voicemail only; M6-T7 adds the rest.
|
||||
; ─── Feature codes (03 §6.13). M4: voicemail; M6-T4: office hours overrides; M6-T7 adds the rest.
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
@@ -93,6 +93,12 @@ exten => *97,1,Answer() ; my voicemail
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,VoiceMailMain(@default)
|
||||
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()
|
||||
@@ -163,6 +169,11 @@ exten => s,1,Set(__SP_TARGET=502)
|
||||
same => n,Goto(sp-ext-forward,13125550000,1)
|
||||
same => n(done),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)
|
||||
|
||||
+12
-1
@@ -85,7 +85,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Feature codes (03 §6.13). M4: voicemail only; M6-T7 adds the rest.
|
||||
; ─── Feature codes (03 §6.13). M4: voicemail; M6-T4: office hours overrides; M6-T7 adds the rest.
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
@@ -93,6 +93,12 @@ exten => *97,1,Answer() ; my voicemail
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,VoiceMailMain(@default)
|
||||
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()
|
||||
@@ -124,6 +130,11 @@ include => sp-parkedcalls
|
||||
|
||||
; users (sorted by number)
|
||||
|
||||
; ─── 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 "Carrier B IP auth"
|
||||
[sp-from-trunk-a1b2c3d4-0000-4000-8000-000000000001]
|
||||
exten => s,1,Goto(sp-from-trunk-a1b2c3d4-0000-4000-8000-000000000001,unknown,1)
|
||||
|
||||
+12
-1
@@ -85,7 +85,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Feature codes (03 §6.13). M4: voicemail only; M6-T7 adds the rest.
|
||||
; ─── Feature codes (03 §6.13). M4: voicemail; M6-T4: office hours overrides; M6-T7 adds the rest.
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
@@ -93,6 +93,12 @@ exten => *97,1,Answer() ; my voicemail
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,VoiceMailMain(@default)
|
||||
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()
|
||||
@@ -127,6 +133,11 @@ exten => 101,1,Gosub(sp-sub-user,s,1(101,25,1,never))
|
||||
same => n,Hangup()
|
||||
exten => 101,hint,PJSIP/101&PJSIP/101-web
|
||||
|
||||
; ─── 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)
|
||||
|
||||
+12
-1
@@ -85,7 +85,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Feature codes (03 §6.13). M4: voicemail only; M6-T7 adds the rest.
|
||||
; ─── Feature codes (03 §6.13). M4: voicemail; M6-T4: office hours overrides; M6-T7 adds the rest.
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,VoiceMailMain(${SP_EXT}@default)
|
||||
@@ -93,6 +93,12 @@ exten => *97,1,Answer() ; my voicemail
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,VoiceMailMain(@default)
|
||||
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()
|
||||
@@ -130,6 +136,11 @@ exten => 102,1,Gosub(sp-sub-user,s,1(102,25,0,all))
|
||||
same => n,Hangup()
|
||||
exten => 102,hint,PJSIP/102
|
||||
|
||||
; ─── Office hours overrides (admins only, see sp-featurecodes)
|
||||
[sp-tc-override]
|
||||
exten => _*3[123]X,1,Playback(ss-noservice)
|
||||
same => n,Hangup()
|
||||
|
||||
[sp-inbound]
|
||||
; anything else
|
||||
exten => _[0-9a-z].,1,Hangup()
|
||||
|
||||
@@ -25,8 +25,12 @@ export function destApp(d: Destination, snap: Snapshot): string {
|
||||
return snap.ivrs.some((r) => r.id === d.id)
|
||||
? `Goto(sp-ivr-${s.ident(d.id)},s,1)`
|
||||
: 'Hangup()';
|
||||
case 'time_condition':
|
||||
return snap.timeConditions.some((t) => t.id === d.id)
|
||||
? `Goto(sp-tc-${s.ident(d.id)},s,1)`
|
||||
: 'Hangup()';
|
||||
default:
|
||||
// TODO(M6, M8): queues, time conditions, conferences, announcements.
|
||||
// TODO(M6, M8): queues, conferences, announcements.
|
||||
return 'Hangup()';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
|
||||
same => n,SayDigits(\${EXTEN})
|
||||
same => n,Hangup()
|
||||
|
||||
; ─── Feature codes (03 §6.13). M4: voicemail only; M6-T7 adds the rest.
|
||||
; ─── Feature codes (03 §6.13). M4: voicemail; M6-T4: office hours overrides; M6-T7 adds the rest.
|
||||
[sp-featurecodes]
|
||||
exten => *97,1,Answer() ; my voicemail
|
||||
same => n,VoiceMailMain(\${SP_EXT}@default)
|
||||
@@ -96,6 +96,12 @@ exten => *97,1,Answer() ; my voicemail
|
||||
exten => *98,1,Answer() ; any voicemail (asks mailbox)
|
||||
same => n,VoiceMailMain(@default)
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Office hours (03 §6.10): an override from AstDB wins, then holidays, then the weekly ranges.
|
||||
// Also sp-tc-override, which the admin feature codes *31N/*32N/*33N jump into (03 §6.13).
|
||||
import type { Snapshot, SnapHoliday } from '../load.ts';
|
||||
import * as s from '../sanitize.ts';
|
||||
import { destApp } from './inbound.ts';
|
||||
|
||||
const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
||||
const DAY_MS = 86_400_000;
|
||||
const day = (iso: string) => Date.parse(`${iso}T00:00:00Z`);
|
||||
const isoOf = (ms: number) => new Date(ms).toISOString().slice(0, 10);
|
||||
|
||||
/** GotoIfTime's end minute is inclusive, ours is exclusive: closing at 17:00 renders 16:59. */
|
||||
function minuteBefore(hhmm: string): string {
|
||||
const [h, m] = hhmm.split(':').map(Number) as [number, number];
|
||||
const t = h * 60 + m - 1;
|
||||
return `${String(Math.floor(t / 60)).padStart(2, '0')}:${String(t % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every (day, month) a holiday covers. GotoIfTime can't match a year, so a one-off holiday is
|
||||
* only rendered while it's within the coming year (the nightly regenerate drops past ones).
|
||||
*/
|
||||
function holidayDays(hs: SnapHoliday[], today: string): string[] {
|
||||
const from = day(today);
|
||||
const until = from + 364 * DAY_MS;
|
||||
const out = new Set<string>();
|
||||
for (const h of hs)
|
||||
for (let t = day(h.startDate); t <= day(h.endDate); t += DAY_MS) {
|
||||
if (!h.recurringYearly && (t < from || t > until)) continue;
|
||||
const iso = isoOf(t);
|
||||
out.add(`${Number(iso.slice(5, 7))}|${Number(iso.slice(8, 10))}`);
|
||||
}
|
||||
return [...out]
|
||||
.map((k) => k.split('|').map(Number) as [number, number])
|
||||
.sort((a, b) => a[0] - b[0] || a[1] - b[1])
|
||||
.map(([m, d]) => `${d},${MONTHS[m - 1]}`);
|
||||
}
|
||||
|
||||
export function renderTimeConditions(snap: Snapshot): string {
|
||||
const tcs = [...snap.timeConditions].sort((a, b) => a.id.localeCompare(b.id));
|
||||
const contexts = tcs.map((t) => {
|
||||
const id = s.ident(t.id);
|
||||
const tz = s.timezone(t.timezone);
|
||||
return [
|
||||
`; ─── Office hours "${s.name(t.name)}"`,
|
||||
`[sp-tc-${id}]`,
|
||||
`exten => s,1,Set(OVR=\${DB(SP/TCOVR/${id})})`,
|
||||
' same => n,GotoIf($["${OVR}" = "open"]?open)',
|
||||
' same => n,GotoIf($["${OVR}" = "closed"]?closed)',
|
||||
...(t.useHolidays
|
||||
? holidayDays(snap.holidays, snap.today).map(
|
||||
(dm) => ` same => n,GotoIfTime(*,*,${dm},${tz}?holiday)`,
|
||||
)
|
||||
: []),
|
||||
...t.openRanges.map(
|
||||
(r) =>
|
||||
` same => n,GotoIfTime(${s.hhmm(r.start)}-${minuteBefore(s.hhmm(r.end))},${r.days.map(s.weekday).join('&')},*,*,${tz}?open)`,
|
||||
),
|
||||
' same => n,Goto(closed)',
|
||||
` same => n(open),${destApp(t.openDestination, snap)}`,
|
||||
` same => n(closed),${destApp(t.closedDestination, snap)}`,
|
||||
` same => n(holiday),${destApp(t.holidayDestination ?? t.closedDestination, snap)}`,
|
||||
'',
|
||||
].join('\n');
|
||||
});
|
||||
|
||||
// *31N force open, *32N force closed, *33N automatic. Unknown N: "no service".
|
||||
const override = [
|
||||
'; ─── Office hours overrides (admins only, see sp-featurecodes)',
|
||||
'[sp-tc-override]',
|
||||
];
|
||||
for (const t of tcs.filter((x) => x.overrideDigit !== null)) {
|
||||
const id = s.ident(t.id);
|
||||
const n = s.digits(String(t.overrideDigit));
|
||||
for (const [code, value, set, sound] of [
|
||||
[`*31${n}`, 'open', `Set(DB(SP/TCOVR/${id})=open)`, 'activated'],
|
||||
[`*32${n}`, 'closed', `Set(DB(SP/TCOVR/${id})=closed)`, 'activated'],
|
||||
[`*33${n}`, 'auto', `Set(X=\${DB_DELETE(SP/TCOVR/${id})})`, 'de-activated'],
|
||||
])
|
||||
override.push(
|
||||
`exten => ${code},1,${set}`,
|
||||
` same => n,UserEvent(SpToggle,Key: TCOVR,Id: ${id},Value: ${value})`,
|
||||
` same => n,Playback(${sound})`,
|
||||
' same => n,Hangup()',
|
||||
);
|
||||
}
|
||||
override.push('exten => _*3[123]X,1,Playback(ss-noservice)', ' same => n,Hangup()', '');
|
||||
return [...contexts, override.join('\n')].join('\n');
|
||||
}
|
||||
@@ -19,6 +19,8 @@ export function loadCase(name: string): Snapshot {
|
||||
routes?: Partial<Snapshot['routes'][number]>[];
|
||||
ringGroups?: Partial<Snapshot['ringGroups'][number]>[];
|
||||
ivrs?: Partial<Snapshot['ivrs'][number]>[];
|
||||
timeConditions?: Partial<Snapshot['timeConditions'][number]>[];
|
||||
holidays?: Snapshot['holidays'];
|
||||
};
|
||||
const settings = Object.fromEntries(
|
||||
(Object.keys(SettingsSchemas) as SettingKey[])
|
||||
@@ -37,6 +39,19 @@ export function loadCase(name: string): Snapshot {
|
||||
id: d.id!,
|
||||
number: d.number!,
|
||||
})),
|
||||
timeConditions: (raw.timeConditions ?? []).map((t, i) => ({
|
||||
name: `Hours ${i + 1}`,
|
||||
timezone: 'America/Chicago',
|
||||
openRanges: [],
|
||||
useHolidays: true,
|
||||
overrideDigit: null,
|
||||
openDestination: { type: 'hangup' as const },
|
||||
closedDestination: { type: 'hangup' as const },
|
||||
holidayDestination: null,
|
||||
...t,
|
||||
id: t.id!,
|
||||
})),
|
||||
holidays: raw.holidays ?? [],
|
||||
ivrs: (raw.ivrs ?? []).map((r, i) => ({
|
||||
name: `Menu ${i + 1}`,
|
||||
greeting: 'custom/greeting',
|
||||
|
||||
@@ -23,6 +23,8 @@ describe('golden', () => {
|
||||
'voipms-basic',
|
||||
'ringgroups',
|
||||
'ivr',
|
||||
'holidays',
|
||||
'ivr-business-hours',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import { renderOutbound } from './dialplan/outbound.ts';
|
||||
import { renderEmergency } from './dialplan/emergency.ts';
|
||||
import { renderRingGroups } from './dialplan/ringgroups.ts';
|
||||
import { renderIvrs } from './dialplan/ivrs.ts';
|
||||
import { renderTimeConditions } from './dialplan/timeconditions.ts';
|
||||
import { renderVoicemail } from './voicemail.ts';
|
||||
|
||||
export { renderTransports };
|
||||
@@ -52,6 +53,7 @@ const BODIES: Partial<Record<GeneratedFileName, (snap: Snapshot) => string>> = {
|
||||
renderLocalNumbers(snap),
|
||||
renderRingGroups(snap),
|
||||
renderIvrs(snap),
|
||||
renderTimeConditions(snap),
|
||||
renderTrunkEntries(snap),
|
||||
renderDids(snap),
|
||||
renderOutbound(snap),
|
||||
|
||||
@@ -6,18 +6,20 @@ import type { Db } from '../db/client.ts';
|
||||
import {
|
||||
dids,
|
||||
extensions,
|
||||
holidays,
|
||||
ivrOptions,
|
||||
ivrs,
|
||||
outboundRoutes,
|
||||
ringGroupMembers,
|
||||
ringGroups,
|
||||
soundFiles,
|
||||
timeConditions,
|
||||
trunks,
|
||||
users,
|
||||
} from '../db/schema.ts';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
import { getAllSettings } from '../services/settings.ts';
|
||||
import type { Destination, OutboundClass, RoutePattern } from '@selfphoneng/shared';
|
||||
import type { Destination, OpenRange, OutboundClass, RoutePattern } from '@selfphoneng/shared';
|
||||
|
||||
export type SnapExtension = {
|
||||
id: string;
|
||||
@@ -104,6 +106,25 @@ export type SnapIvr = {
|
||||
invalidDestination: Destination;
|
||||
};
|
||||
|
||||
export type SnapTimeCondition = {
|
||||
id: string;
|
||||
name: string;
|
||||
timezone: string;
|
||||
openRanges: OpenRange[];
|
||||
useHolidays: boolean;
|
||||
overrideDigit: number | null;
|
||||
openDestination: Destination;
|
||||
closedDestination: Destination;
|
||||
holidayDestination: Destination | null;
|
||||
};
|
||||
|
||||
export type SnapHoliday = {
|
||||
name: string;
|
||||
startDate: string; // YYYY-MM-DD
|
||||
endDate: string;
|
||||
recurringYearly: boolean;
|
||||
};
|
||||
|
||||
export type Snapshot = {
|
||||
/** "YYYY-MM-DD" in general.timezone; render() never reads the clock. */
|
||||
today: string;
|
||||
@@ -114,6 +135,8 @@ export type Snapshot = {
|
||||
routes: SnapRoute[];
|
||||
ringGroups: SnapRingGroup[];
|
||||
ivrs: SnapIvr[];
|
||||
timeConditions: SnapTimeCondition[];
|
||||
holidays: SnapHoliday[];
|
||||
};
|
||||
|
||||
export function todayIn(timezone: string, now = new Date()): string {
|
||||
@@ -151,6 +174,8 @@ export async function loadSnapshot(db: Db, now = new Date()): Promise<Snapshot>
|
||||
const routeRows = await db.select().from(outboundRoutes);
|
||||
const groupRows = await db.select().from(ringGroups);
|
||||
const ivrRows = await db.select().from(ivrs);
|
||||
const tcRows = await db.select().from(timeConditions);
|
||||
const holidayRows = await db.select().from(holidays);
|
||||
const optionRows = await db.select().from(ivrOptions);
|
||||
const soundName = new Map(
|
||||
(await db.select({ id: soundFiles.id, name: soundFiles.asteriskName }).from(soundFiles)).map(
|
||||
@@ -167,6 +192,23 @@ export async function loadSnapshot(db: Db, now = new Date()): Promise<Snapshot>
|
||||
today: todayIn(settings['general.timezone'], now),
|
||||
settings,
|
||||
dids: didRows.map((d) => ({ ...d, destination: d.destination as Destination })),
|
||||
timeConditions: tcRows.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
timezone: t.timezone,
|
||||
openRanges: t.openRanges as OpenRange[],
|
||||
useHolidays: t.useHolidays,
|
||||
overrideDigit: t.overrideDigit,
|
||||
openDestination: t.openDestination as Destination,
|
||||
closedDestination: t.closedDestination as Destination,
|
||||
holidayDestination: t.holidayDestination as Destination | null,
|
||||
})),
|
||||
holidays: holidayRows.map((h) => ({
|
||||
name: h.name,
|
||||
startDate: h.startDate,
|
||||
endDate: h.endDate,
|
||||
recurringYearly: h.recurringYearly,
|
||||
})),
|
||||
ivrs: ivrRows.map((r) => ({
|
||||
id: r.id,
|
||||
number: r.number,
|
||||
|
||||
@@ -18,6 +18,10 @@ export const digits = check(/^[0-9]+$/, 'digits', 32);
|
||||
export const dialable = check(/^[0-9*#+]+$/, 'dialable', 32);
|
||||
/** Outbound route patterns. */
|
||||
export const pattern = check(/^[0-9XZN.![\]-]+$/, 'pattern', 32);
|
||||
/** Office hours times "08:30"; "24:00" is the end of the day. */
|
||||
export const hhmm = check(/^(?:[01]\d|2[0-3]):[0-5]\d$|^24:00$/, 'time', 5);
|
||||
/** Day names as GotoIfTime wants them. */
|
||||
export const weekday = check(/^(?:mon|tue|wed|thu|fri|sat|sun)$/, 'weekday', 3);
|
||||
/** One IVR key. */
|
||||
export const ivrDigit = check(/^[0-9*#]$/, 'ivr digit', 1);
|
||||
/** Sound file names for Playback/Background. */
|
||||
|
||||
@@ -12,6 +12,10 @@ import { LiveState } from './live/state.ts';
|
||||
import { smtpMailer } from './services/mail.ts';
|
||||
import { watchEmergencies } from './services/emergency.ts';
|
||||
import { watchVoicemail } from './services/voicemail.ts';
|
||||
import { syncAstDb } from './services/astdb.ts';
|
||||
import { watchToggles } from './services/toggles.ts';
|
||||
import { getSetting } from './services/settings.ts';
|
||||
import { todayIn } from './generator/load.ts';
|
||||
|
||||
let config;
|
||||
try {
|
||||
@@ -51,6 +55,7 @@ app.decorate('ami', ami);
|
||||
const sendMail = smtpMailer(db);
|
||||
app.decorate('sendMail', sendMail);
|
||||
watchEmergencies({ db, ami, live, send: sendMail, log: app.log }, ami);
|
||||
watchToggles({ db, log: app.log }, ami);
|
||||
const voicemail = watchVoicemail(
|
||||
{
|
||||
db,
|
||||
@@ -84,6 +89,7 @@ ami.on('connected', () => {
|
||||
void (async () => {
|
||||
if (await clearRestartIfRestarted(db, ami))
|
||||
app.log.info('asterisk restarted: restart no longer required');
|
||||
await syncAstDb(db, ami); // PostgreSQL wins (04 §5.5)
|
||||
await generator.apply({ trigger: 'startup', force: true });
|
||||
await live.loadFromAsterisk();
|
||||
await voicemail.scanAll();
|
||||
@@ -91,9 +97,25 @@ ami.on('connected', () => {
|
||||
});
|
||||
ami.start();
|
||||
|
||||
// Holidays roll over at midnight (03 §6.10): render again when the local date changes.
|
||||
// ponytail: a 10-minute check instead of croner; the M11 jobs can take this over.
|
||||
let lastDay = '';
|
||||
const regenerateOnNewDay = async () => {
|
||||
const today = todayIn(await getSetting(db, 'general.timezone'));
|
||||
if (lastDay && today !== lastDay) await generator.apply({ trigger: 'nightly', force: true });
|
||||
lastDay = today;
|
||||
};
|
||||
void regenerateOnNewDay();
|
||||
const dayTimer = setInterval(
|
||||
() =>
|
||||
void regenerateOnNewDay().catch((err) => app.log.error({ err }, 'nightly regenerate failed')),
|
||||
10 * 60_000,
|
||||
);
|
||||
|
||||
for (const sig of ['SIGTERM', 'SIGINT'] as const) {
|
||||
process.once(sig, () => {
|
||||
generator.cancelPending();
|
||||
clearInterval(dayTimer);
|
||||
ami.stop();
|
||||
void app
|
||||
.close()
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /api/time-conditions (office hours) and /api/holidays (04 §7.3). Admin only.
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
HolidaySchema,
|
||||
OverrideBodySchema,
|
||||
TimeConditionCreateSchema,
|
||||
TimeConditionUpdateSchema,
|
||||
timeConditionStatus,
|
||||
type Destination,
|
||||
type HolidayDto,
|
||||
type ListResponse,
|
||||
type OpenRange,
|
||||
type TimeConditionDto,
|
||||
} from '@selfphoneng/shared';
|
||||
import { requireAdmin } from '../auth/guards.ts';
|
||||
import { holidays, timeConditions } from '../db/schema.ts';
|
||||
import { badRequest, conflict, HttpError, notFound } from '../errors.ts';
|
||||
import { setTcOverride } from '../services/astdb.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import { assertDestinationExists, findReferences } from '../services/destinations.ts';
|
||||
import { usFederalHolidays } from '../services/holidays.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
|
||||
type TcRow = typeof timeConditions.$inferSelect;
|
||||
type HolidayRow = typeof holidays.$inferSelect;
|
||||
|
||||
const holidayDto = (h: HolidayRow): HolidayDto => ({
|
||||
id: h.id,
|
||||
name: h.name,
|
||||
startDate: h.startDate,
|
||||
endDate: h.endDate,
|
||||
recurringYearly: h.recurringYearly,
|
||||
});
|
||||
|
||||
function tcDto(t: TcRow, hs: HolidayRow[], now = new Date()): TimeConditionDto {
|
||||
const base = {
|
||||
timezone: t.timezone,
|
||||
openRanges: t.openRanges as OpenRange[],
|
||||
useHolidays: t.useHolidays,
|
||||
overrideState: t.overrideState as 'open' | 'closed' | null,
|
||||
};
|
||||
return {
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
...base,
|
||||
overrideDigit: t.overrideDigit,
|
||||
openDestination: t.openDestination as Destination,
|
||||
closedDestination: t.closedDestination as Destination,
|
||||
holidayDestination: t.holidayDestination as Destination | null,
|
||||
status: timeConditionStatus(base, hs, now),
|
||||
createdAt: t.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const IdParams = z.object({ id: z.uuid() });
|
||||
const isUniqueViolation = (e: unknown) =>
|
||||
((e as { cause?: { code?: string } }).cause?.code ?? (e as { code?: string }).code) === '23505';
|
||||
const digitTaken = (d: number | null | undefined) =>
|
||||
conflict(`Override digit ${d} is used by other office hours (*31${d}, *32${d}, *33${d})`);
|
||||
|
||||
export async function timeConditionRoutes(app: FastifyInstance) {
|
||||
const { db } = app;
|
||||
app.addHook('preHandler', requireAdmin);
|
||||
const allHolidays = () => db.select().from(holidays).orderBy(asc(holidays.startDate));
|
||||
|
||||
async function getTc(id: string) {
|
||||
const [t] = await db.select().from(timeConditions).where(eq(timeConditions.id, id));
|
||||
if (!t) throw notFound('Office hours not found');
|
||||
return t;
|
||||
}
|
||||
const checkDests = async (ds: (Destination | null | undefined)[], self?: string) => {
|
||||
for (const d of ds) {
|
||||
if (!d) continue;
|
||||
if (self && d.type === 'time_condition' && d.id === self)
|
||||
throw badRequest("Office hours can't send calls to themselves");
|
||||
await assertDestinationExists(db, d);
|
||||
}
|
||||
};
|
||||
|
||||
app.get('/api/time-conditions', async (): Promise<ListResponse<TimeConditionDto>> => {
|
||||
const [rows, hs] = await Promise.all([
|
||||
db.select().from(timeConditions).orderBy(asc(timeConditions.name)),
|
||||
allHolidays(),
|
||||
]);
|
||||
return { items: rows.map((t) => tcDto(t, hs)), nextCursor: null };
|
||||
});
|
||||
|
||||
app.get('/api/time-conditions/:id', async (req) =>
|
||||
tcDto(await getTc(IdParams.parse(req.params).id), await allHolidays()),
|
||||
);
|
||||
|
||||
app.get('/api/time-conditions/:id/status', async (req) => {
|
||||
const t = await getTc(IdParams.parse(req.params).id);
|
||||
return tcDto(t, await allHolidays()).status;
|
||||
});
|
||||
|
||||
app.post('/api/time-conditions', async (req, reply) => {
|
||||
const body = TimeConditionCreateSchema.parse(req.body);
|
||||
await checkDests([body.openDestination, body.closedDestination, body.holidayDestination]);
|
||||
const values = {
|
||||
...body,
|
||||
timezone: body.timezone ?? (await getSetting(db, 'general.timezone')),
|
||||
openRanges: body.openRanges ?? [],
|
||||
};
|
||||
let created: TcRow;
|
||||
try {
|
||||
[created] = (await db.insert(timeConditions).values(values).returning()) as [TcRow];
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e)) throw digitTaken(body.overrideDigit);
|
||||
throw e;
|
||||
}
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'create',
|
||||
entityType: 'time_condition',
|
||||
entityId: created.id,
|
||||
summary: `Created office hours ${created.name}`,
|
||||
diff: { after: values },
|
||||
});
|
||||
app.generator.requestApply('api-change');
|
||||
return reply.code(201).send(tcDto(created, await allHolidays()));
|
||||
});
|
||||
|
||||
app.patch('/api/time-conditions/:id', async (req) => {
|
||||
const { id } = IdParams.parse(req.params);
|
||||
const body = TimeConditionUpdateSchema.parse(req.body);
|
||||
await getTc(id);
|
||||
await checkDests([body.openDestination, body.closedDestination, body.holidayDestination], id);
|
||||
let after: TcRow;
|
||||
try {
|
||||
[after] = (await db
|
||||
.update(timeConditions)
|
||||
.set(body)
|
||||
.where(eq(timeConditions.id, id))
|
||||
.returning()) as [TcRow];
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e)) throw digitTaken(body.overrideDigit);
|
||||
throw e;
|
||||
}
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'update',
|
||||
entityType: 'time_condition',
|
||||
entityId: id,
|
||||
summary: `Updated office hours ${after.name}`,
|
||||
diff: { after: body },
|
||||
});
|
||||
app.generator.requestApply('api-change');
|
||||
return tcDto(after, await allHolidays());
|
||||
});
|
||||
|
||||
/** Force open / force closed / back to automatic; the dialplan reads AstDB SP/TCOVR/<id>. */
|
||||
app.post('/api/time-conditions/:id/override', async (req) => {
|
||||
const { id } = IdParams.parse(req.params);
|
||||
const { state } = OverrideBodySchema.parse(req.body);
|
||||
const t = await getTc(id);
|
||||
if (!app.ami.isConnected())
|
||||
throw new HttpError(503, 'unavailable', 'Asterisk is not connected');
|
||||
const overrideState = state === 'auto' ? null : state;
|
||||
const [after] = (await db
|
||||
.update(timeConditions)
|
||||
.set({ overrideState })
|
||||
.where(eq(timeConditions.id, id))
|
||||
.returning()) as [TcRow];
|
||||
await setTcOverride(app.ami, id, overrideState);
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'update',
|
||||
entityType: 'time_condition',
|
||||
entityId: id,
|
||||
summary: `${t.name}: ${state === 'auto' ? 'back to automatic' : `forced ${state}`}`,
|
||||
});
|
||||
return tcDto(after, await allHolidays());
|
||||
});
|
||||
|
||||
app.delete('/api/time-conditions/:id', async (req) => {
|
||||
const { id } = IdParams.parse(req.params);
|
||||
const t = await getTc(id);
|
||||
const usedBy = await findReferences(db, 'time_condition', id);
|
||||
if (usedBy.length)
|
||||
throw conflict(`${t.name} is still used by ${usedBy.join(', ')}`, { usedBy });
|
||||
await db.delete(timeConditions).where(eq(timeConditions.id, id));
|
||||
if (t.overrideState && app.ami.isConnected()) await setTcOverride(app.ami, id, null);
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'delete',
|
||||
entityType: 'time_condition',
|
||||
entityId: id,
|
||||
summary: `Deleted office hours ${t.name}`,
|
||||
diff: { before: t },
|
||||
});
|
||||
app.generator.requestApply('api-change');
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// ─── Holidays: global, used by every office hours with "use holidays" on.
|
||||
app.get('/api/holidays', async (): Promise<ListResponse<HolidayDto>> => ({
|
||||
items: (await allHolidays()).map(holidayDto),
|
||||
nextCursor: null,
|
||||
}));
|
||||
|
||||
app.post('/api/holidays', async (req, reply) => {
|
||||
const body = HolidaySchema.parse(req.body);
|
||||
const [h] = (await db.insert(holidays).values(body).returning()) as [HolidayRow];
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'create',
|
||||
entityType: 'holiday',
|
||||
entityId: h.id,
|
||||
summary: `Added holiday ${h.name} (${h.startDate})`,
|
||||
});
|
||||
app.generator.requestApply('api-change');
|
||||
return reply.code(201).send(holidayDto(h));
|
||||
});
|
||||
|
||||
app.patch('/api/holidays/:id', async (req) => {
|
||||
const { id } = IdParams.parse(req.params);
|
||||
const body = HolidaySchema.parse(req.body); // whole holiday: the date rules need both ends
|
||||
const [h] = (await db.update(holidays).set(body).where(eq(holidays.id, id)).returning()) as [
|
||||
HolidayRow | undefined,
|
||||
];
|
||||
if (!h) throw notFound('Holiday not found');
|
||||
app.generator.requestApply('api-change');
|
||||
return holidayDto(h);
|
||||
});
|
||||
|
||||
app.delete('/api/holidays/:id', async (req) => {
|
||||
const { id } = IdParams.parse(req.params);
|
||||
const [h] = await db.delete(holidays).where(eq(holidays.id, id)).returning();
|
||||
if (!h) throw notFound('Holiday not found');
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'delete',
|
||||
entityType: 'holiday',
|
||||
entityId: id,
|
||||
summary: `Removed holiday ${h.name} (${h.startDate})`,
|
||||
});
|
||||
app.generator.requestApply('api-change');
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Adds the year's US federal holidays (observed dates); skips any already listed that day. */
|
||||
app.post('/api/holidays/import-us-federal', async (req) => {
|
||||
const { year } = z.object({ year: z.number().int().min(2000).max(2100) }).parse(req.body);
|
||||
const have = new Set((await allHolidays()).map((h) => h.startDate));
|
||||
const add = usFederalHolidays(year).filter((h) => !have.has(h.date));
|
||||
if (add.length)
|
||||
await db
|
||||
.insert(holidays)
|
||||
.values(add.map((h) => ({ name: h.name, startDate: h.date, endDate: h.date })));
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'create',
|
||||
entityType: 'holiday',
|
||||
summary: `Added ${add.length} US federal holidays for ${year}`,
|
||||
});
|
||||
if (add.length) app.generator.requestApply('api-change');
|
||||
return { added: add.length };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// AstDB holds what the dialplan reads at call time (overrides, DND, forwards, blocks). PostgreSQL
|
||||
// is the truth: routes write it first, then call one of these; every AMI connect resyncs (04 §5.5).
|
||||
import { isNotNull } from 'drizzle-orm';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { timeConditions } from '../db/schema.ts';
|
||||
|
||||
export type AstDbAmi = { action(fields: Record<string, string>): Promise<unknown> };
|
||||
|
||||
const put = (ami: AstDbAmi, key: string, val: string) =>
|
||||
ami.action({ Action: 'DBPut', Family: 'SP', Key: key, Val: val });
|
||||
/** Deleting a key that isn't there is an AMI error; that's fine. */
|
||||
const del = (ami: AstDbAmi, key: string) =>
|
||||
ami.action({ Action: 'DBDel', Family: 'SP', Key: key }).catch(() => undefined);
|
||||
|
||||
export const setTcOverride = (ami: AstDbAmi, id: string, state: 'open' | 'closed' | null) =>
|
||||
state ? put(ami, `TCOVR/${id}`, state) : del(ami, `TCOVR/${id}`);
|
||||
|
||||
/** Wipe our keys and write them again from PostgreSQL. M6-T7/T8 add DND, forwards, blocks. */
|
||||
export async function syncAstDb(db: Db, ami: AstDbAmi): Promise<void> {
|
||||
await ami.action({ Action: 'DBDelTree', Family: 'SP' }).catch(() => undefined); // empty tree
|
||||
const overrides = await db
|
||||
.select({ id: timeConditions.id, state: timeConditions.overrideState })
|
||||
.from(timeConditions)
|
||||
.where(isNotNull(timeConditions.overrideState));
|
||||
for (const o of overrides) await setTcOverride(ami, o.id, o.state as 'open' | 'closed');
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { usFederalHolidays } from './holidays.ts';
|
||||
|
||||
describe('usFederalHolidays', () => {
|
||||
it('2026 (July 4 is a Saturday → observed Friday July 3)', () => {
|
||||
expect(usFederalHolidays(2026)).toEqual([
|
||||
{ name: "New Year's Day", date: '2026-01-01' },
|
||||
{ name: 'Martin Luther King Jr. Day', date: '2026-01-19' },
|
||||
{ name: "Washington's Birthday", date: '2026-02-16' },
|
||||
{ name: 'Memorial Day', date: '2026-05-25' },
|
||||
{ name: 'Juneteenth', date: '2026-06-19' },
|
||||
{ name: 'Independence Day (observed)', date: '2026-07-03' },
|
||||
{ name: 'Labor Day', date: '2026-09-07' },
|
||||
{ name: 'Columbus Day', date: '2026-10-12' },
|
||||
{ name: 'Veterans Day', date: '2026-11-11' },
|
||||
{ name: 'Thanksgiving Day', date: '2026-11-26' },
|
||||
{ name: 'Christmas Day', date: '2026-12-25' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('2027: Sunday and Saturday holidays move; Memorial Day is the last Monday', () => {
|
||||
const h = Object.fromEntries(usFederalHolidays(2027).map((x) => [x.name, x.date]));
|
||||
expect(h['Juneteenth (observed)']).toBe('2027-06-18');
|
||||
expect(h['Independence Day (observed)']).toBe('2027-07-05');
|
||||
expect(h['Christmas Day (observed)']).toBe('2027-12-24');
|
||||
expect(h['Memorial Day']).toBe('2027-05-31');
|
||||
expect(h['Thanksgiving Day']).toBe('2027-11-25');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// US federal holidays for one year (04 §7.3 POST /api/holidays/import-us-federal), computed here:
|
||||
// no network. Dates are the days offices close: a Saturday holiday is observed on Friday, a
|
||||
// Sunday one on Monday (5 U.S.C. 6103).
|
||||
|
||||
const iso = (d: Date) => d.toISOString().slice(0, 10);
|
||||
const utc = (y: number, m: number, d: number) => new Date(Date.UTC(y, m - 1, d));
|
||||
|
||||
/** The n-th `weekday` (0 = Sunday) of a month; n = -1 is the last one. */
|
||||
function nth(y: number, m: number, weekday: number, n: number): Date {
|
||||
if (n < 0) {
|
||||
const last = utc(y, m + 1, 0);
|
||||
return utc(y, m, last.getUTCDate() - ((last.getUTCDay() - weekday + 7) % 7));
|
||||
}
|
||||
const first = utc(y, m, 1);
|
||||
return utc(y, m, 1 + ((weekday - first.getUTCDay() + 7) % 7) + 7 * (n - 1));
|
||||
}
|
||||
|
||||
function observed(d: Date): { date: Date; moved: boolean } {
|
||||
const wd = d.getUTCDay();
|
||||
if (wd === 6) return { date: new Date(d.getTime() - 86_400_000), moved: true };
|
||||
if (wd === 0) return { date: new Date(d.getTime() + 86_400_000), moved: true };
|
||||
return { date: d, moved: false };
|
||||
}
|
||||
|
||||
export function usFederalHolidays(year: number): { name: string; date: string }[] {
|
||||
const fixed = (name: string, m: number, d: number) => {
|
||||
const o = observed(utc(year, m, d));
|
||||
return { name: o.moved ? `${name} (observed)` : name, date: iso(o.date) };
|
||||
};
|
||||
return [
|
||||
fixed("New Year's Day", 1, 1),
|
||||
{ name: 'Martin Luther King Jr. Day', date: iso(nth(year, 1, 1, 3)) },
|
||||
{ name: "Washington's Birthday", date: iso(nth(year, 2, 1, 3)) },
|
||||
{ name: 'Memorial Day', date: iso(nth(year, 5, 1, -1)) },
|
||||
fixed('Juneteenth', 6, 19),
|
||||
fixed('Independence Day', 7, 4),
|
||||
{ name: 'Labor Day', date: iso(nth(year, 9, 1, 1)) },
|
||||
{ name: 'Columbus Day', date: iso(nth(year, 10, 1, 2)) },
|
||||
fixed('Veterans Day', 11, 11),
|
||||
{ name: 'Thanksgiving Day', date: iso(nth(year, 11, 4, 4)) },
|
||||
fixed('Christmas Day', 12, 25),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Feature codes change AstDB from a phone, then raise UserEvent(SpToggle, Key, ...) so PostgreSQL
|
||||
// follows (04 §5.3). M6-T4: TCOVR (*31N/*32N/*33N). M6-T7 adds DND and forwards.
|
||||
import type { EventEmitter } from 'node:events';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { field, type AmiMessage } from '../ami/events.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { timeConditions } from '../db/schema.ts';
|
||||
|
||||
export type ToggleDeps = {
|
||||
db: Db;
|
||||
log: { info(o: object, msg: string): void; error(o: object, msg: string): void };
|
||||
};
|
||||
|
||||
export async function handleToggle(d: ToggleDeps, m: AmiMessage): Promise<void> {
|
||||
const key = field(m, 'Key');
|
||||
if (key === 'TCOVR') {
|
||||
const id = field(m, 'Id') ?? '';
|
||||
const value = field(m, 'Value');
|
||||
if (!/^[0-9a-f-]{36}$/.test(id) || !['open', 'closed', 'auto'].includes(value ?? '')) return;
|
||||
await d.db
|
||||
.update(timeConditions)
|
||||
.set({ overrideState: value === 'auto' ? null : value })
|
||||
.where(eq(timeConditions.id, id));
|
||||
d.log.info({ id, value }, 'office hours override from a phone');
|
||||
}
|
||||
}
|
||||
|
||||
export function watchToggles(d: ToggleDeps, ami: Pick<EventEmitter, 'on'>) {
|
||||
ami.on('event', (m: AmiMessage) => {
|
||||
if (field(m, 'Event') !== 'UserEvent' || field(m, 'UserEvent') !== 'SpToggle') return;
|
||||
void handleToggle(d, m).catch((err: unknown) => d.log.error({ err }, 'SpToggle failed'));
|
||||
});
|
||||
}
|
||||
@@ -66,6 +66,8 @@ function snapshot(): Snapshot {
|
||||
routes: [],
|
||||
ringGroups: [],
|
||||
ivrs: [],
|
||||
timeConditions: [],
|
||||
holidays: [],
|
||||
extensions: [ext('101', 'Alice'), ext('102', 'Bob')],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -102,6 +102,8 @@ function snapshot(): Snapshot {
|
||||
trunks: [trunk(GOOD, 'voip.ms', SUB.pass), trunk(BAD, 'wrong password', 'NotThePassword1')],
|
||||
ringGroups: [],
|
||||
ivrs: [],
|
||||
timeConditions: [],
|
||||
holidays: [],
|
||||
routes: [
|
||||
{
|
||||
id: 'aaaaaaaa-0000-4000-8000-000000000001',
|
||||
|
||||
@@ -61,6 +61,8 @@ function snapshot(): Snapshot {
|
||||
routes: [],
|
||||
ringGroups: [],
|
||||
ivrs: [],
|
||||
timeConditions: [],
|
||||
holidays: [],
|
||||
extensions: [ext('101', 'Alice'), ext('102', 'Bob')],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M6-T4: /api/time-conditions, /api/holidays, override codes (03 §6.10, §6.13, 04 §5.3, §5.5).
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { timeConditions } from '../src/db/schema.ts';
|
||||
import { handleToggle } from '../src/services/toggles.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
describe.skipIf(!hasTestDb)('office hours and holidays', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let req: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
|
||||
let dan: string;
|
||||
let id: string;
|
||||
const dialplan = async () => {
|
||||
await t.app.generator.apply({ trigger: 'test' });
|
||||
return readFile(join(t.genDir, 'extensions_selfphoneng.conf'), 'utf8');
|
||||
};
|
||||
const weekdays = { days: ['mon', 'tue', 'wed', 'thu', 'fri'], start: '08:00', end: '17:00' };
|
||||
|
||||
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 },
|
||||
});
|
||||
dan = (await req('POST', '/api/extensions', { number: '101', name: 'Dan Doe' })).json().id;
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
|
||||
it('creates office hours (company time zone by default) with a live status', async () => {
|
||||
const r = await req('POST', '/api/time-conditions', {
|
||||
name: 'Business hours',
|
||||
openRanges: [weekdays],
|
||||
overrideDigit: 1,
|
||||
openDestination: { type: 'user', id: dan },
|
||||
closedDestination: { type: 'voicemail', id: dan },
|
||||
});
|
||||
expect(r.statusCode, r.body).toBe(201);
|
||||
id = r.json().id;
|
||||
expect(r.json()).toMatchObject({
|
||||
timezone: 'America/Chicago',
|
||||
useHolidays: true,
|
||||
overrideState: null,
|
||||
holidayDestination: null,
|
||||
status: { reason: 'hours' },
|
||||
});
|
||||
const conf = await dialplan();
|
||||
expect(conf).toContain(`[sp-tc-${id}]`);
|
||||
expect(conf).toContain('GotoIfTime(08:00-16:59,mon&tue&wed&thu&fri,*,*,America/Chicago?open)');
|
||||
expect(conf).toContain(`exten => *311,1,Set(DB(SP/TCOVR/${id})=open)`);
|
||||
});
|
||||
|
||||
it('validates ranges, time zone, digit and destinations', async () => {
|
||||
const post = (body: object) =>
|
||||
req('POST', '/api/time-conditions', {
|
||||
name: 'X',
|
||||
openDestination: { type: 'hangup' },
|
||||
closedDestination: { type: 'hangup' },
|
||||
...body,
|
||||
});
|
||||
expect(
|
||||
(await post({ openRanges: [{ ...weekdays, start: '17:00', end: '08:00' }] })).statusCode,
|
||||
).toBe(400);
|
||||
expect((await post({ openRanges: [{ ...weekdays, end: '25:00' }] })).statusCode).toBe(400);
|
||||
expect((await post({ timezone: 'Mars/Olympus' })).statusCode).toBe(400);
|
||||
expect((await post({ overrideDigit: 1 })).statusCode).toBe(409);
|
||||
const self = await req('PATCH', `/api/time-conditions/${id}`, {
|
||||
closedDestination: { type: 'time_condition', id },
|
||||
});
|
||||
expect(self.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('force closed / automatic: database, AstDB and status follow', async () => {
|
||||
const closed = await req('POST', `/api/time-conditions/${id}/override`, { state: 'closed' });
|
||||
expect(closed.json()).toMatchObject({
|
||||
overrideState: 'closed',
|
||||
status: { now: 'closed', reason: 'override' },
|
||||
});
|
||||
expect(t.ami.actions.at(-1)).toEqual({
|
||||
Action: 'DBPut',
|
||||
Family: 'SP',
|
||||
Key: `TCOVR/${id}`,
|
||||
Val: 'closed',
|
||||
});
|
||||
const auto = await req('POST', `/api/time-conditions/${id}/override`, { state: 'auto' });
|
||||
expect(auto.json().overrideState).toBeNull();
|
||||
expect(t.ami.actions.at(-1)).toEqual({ Action: 'DBDel', Family: 'SP', Key: `TCOVR/${id}` });
|
||||
});
|
||||
|
||||
it('*311 from a phone (SpToggle TCOVR) updates the database', async () => {
|
||||
await handleToggle(
|
||||
{ db: t.db, log: t.app.log },
|
||||
{ Event: 'UserEvent', UserEvent: 'SpToggle', Key: 'TCOVR', Id: id, Value: 'open' },
|
||||
);
|
||||
const [row] = await t.db.select().from(timeConditions).where(eq(timeConditions.id, id));
|
||||
expect(row!.overrideState).toBe('open');
|
||||
await handleToggle(
|
||||
{ db: t.db, log: t.app.log },
|
||||
{ Event: 'UserEvent', UserEvent: 'SpToggle', Key: 'TCOVR', Id: id, Value: 'auto' },
|
||||
);
|
||||
expect((await req('GET', `/api/time-conditions/${id}`)).json().overrideState).toBeNull();
|
||||
});
|
||||
|
||||
it('holidays: add, US federal import (no duplicates), they reach the dialplan', async () => {
|
||||
const bad = await req('POST', '/api/holidays', {
|
||||
name: 'Backwards',
|
||||
startDate: '2026-12-26',
|
||||
endDate: '2026-12-24',
|
||||
});
|
||||
expect(bad.statusCode).toBe(400);
|
||||
const xmas = await req('POST', '/api/holidays', {
|
||||
name: 'Christmas',
|
||||
startDate: '2026-12-25',
|
||||
endDate: '2026-12-25',
|
||||
recurringYearly: true,
|
||||
});
|
||||
expect(xmas.statusCode).toBe(201);
|
||||
expect((await req('POST', '/api/holidays/import-us-federal', { year: 2026 })).json()).toEqual({
|
||||
added: 10, // Christmas (Dec 25) is already listed
|
||||
});
|
||||
expect((await req('POST', '/api/holidays/import-us-federal', { year: 2026 })).json()).toEqual({
|
||||
added: 0,
|
||||
});
|
||||
expect((await req('GET', '/api/holidays')).json().items).toHaveLength(11);
|
||||
const conf = await dialplan();
|
||||
expect(conf).toContain('GotoIfTime(*,*,25,dec,America/Chicago?holiday)');
|
||||
expect(conf).toContain('GotoIfTime(*,*,26,nov,America/Chicago?holiday)'); // Thanksgiving
|
||||
});
|
||||
|
||||
it('delete is refused while a phone number points at it', 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: 'time_condition', id },
|
||||
})
|
||||
).json();
|
||||
expect(did.destinationSummary).toBe('Office hours Business hours');
|
||||
expect((await req('DELETE', `/api/time-conditions/${id}`)).statusCode).toBe(409);
|
||||
await req('PATCH', `/api/dids/${did.id}`, { destination: { type: 'hangup' } });
|
||||
expect((await req('DELETE', `/api/time-conditions/${id}`)).statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,8 @@ import type {
|
||||
ExtensionDto,
|
||||
ExtensionUpdate,
|
||||
GreetingKind,
|
||||
HolidayDto,
|
||||
HolidayInput,
|
||||
IvrCreate,
|
||||
IvrDto,
|
||||
IvrUpdate,
|
||||
@@ -24,6 +26,10 @@ import type {
|
||||
SettingsDto,
|
||||
SipCredentials,
|
||||
SoundDto,
|
||||
TimeConditionCreate,
|
||||
TimeConditionDto,
|
||||
TimeConditionUpdate,
|
||||
OverrideState,
|
||||
TrunkCreate,
|
||||
TrunkDto,
|
||||
TrunkTestResult,
|
||||
@@ -215,3 +221,22 @@ export const ivrsApi = {
|
||||
update: (id: string, r: IvrUpdate) => api<IvrDto>('PATCH', `/api/ivrs/${id}`, r),
|
||||
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/ivrs/${id}`),
|
||||
};
|
||||
|
||||
export const timeConditionsApi = {
|
||||
list: () =>
|
||||
api<ListResponse<TimeConditionDto>>('GET', '/api/time-conditions').then((r) => r.items),
|
||||
create: (t: TimeConditionCreate) => api<TimeConditionDto>('POST', '/api/time-conditions', t),
|
||||
update: (id: string, t: TimeConditionUpdate) =>
|
||||
api<TimeConditionDto>('PATCH', `/api/time-conditions/${id}`, t),
|
||||
override: (id: string, state: OverrideState) =>
|
||||
api<TimeConditionDto>('POST', `/api/time-conditions/${id}/override`, { state }),
|
||||
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/time-conditions/${id}`),
|
||||
};
|
||||
|
||||
export const holidaysApi = {
|
||||
list: () => api<ListResponse<HolidayDto>>('GET', '/api/holidays').then((r) => r.items),
|
||||
create: (h: HolidayInput) => api<HolidayDto>('POST', '/api/holidays', h),
|
||||
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/holidays/${id}`),
|
||||
importUsFederal: (year: number) =>
|
||||
api<{ added: number }>('POST', '/api/holidays/import-us-federal', { year }),
|
||||
};
|
||||
@@ -4,10 +4,10 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useId, useState } from 'react';
|
||||
import { normalizeNumber, type Destination } from '@selfphoneng/shared';
|
||||
import { extensionsApi, ivrsApi, ringGroupsApi } from '../api/client.ts';
|
||||
import { extensionsApi, ivrsApi, ringGroupsApi, timeConditionsApi } from '../api/client.ts';
|
||||
import { inputClass } from './ui.tsx';
|
||||
|
||||
type IdKind = 'user' | 'voicemail' | 'ring_group' | 'ivr';
|
||||
type IdKind = 'user' | 'voicemail' | 'ring_group' | 'ivr' | 'time_condition';
|
||||
type Kind = IdKind | 'external' | 'hangup';
|
||||
|
||||
const KINDS: { kind: Kind; label: string }[] = [
|
||||
@@ -15,6 +15,7 @@ const KINDS: { kind: Kind; label: string }[] = [
|
||||
{ kind: 'voicemail', label: 'Voicemail of a user' },
|
||||
{ kind: 'ring_group', label: 'Ring group' },
|
||||
{ kind: 'ivr', label: 'Digital receptionist' },
|
||||
{ kind: 'time_condition', label: 'Office hours' },
|
||||
{ kind: 'external', label: 'Forward to an outside number' },
|
||||
{ kind: 'hangup', label: 'Hang up' },
|
||||
];
|
||||
@@ -24,6 +25,7 @@ const TARGET_NOUN: Record<IdKind, string> = {
|
||||
voicemail: 'user',
|
||||
ring_group: 'ring group',
|
||||
ivr: 'digital receptionist',
|
||||
time_condition: 'office hours',
|
||||
};
|
||||
const isIdKind = (k: Kind): k is IdKind => k !== 'external' && k !== 'hangup';
|
||||
/** Kinds whose targets are the same rows: switching between them keeps the pick. */
|
||||
@@ -44,19 +46,13 @@ export function DestinationPicker({
|
||||
exclude?: string;
|
||||
}) {
|
||||
const id = useId();
|
||||
const exts = useQuery({ queryKey: ['extensions'], queryFn: extensionsApi.list });
|
||||
const groups = useQuery({ queryKey: ['ring-groups'], queryFn: ringGroupsApi.list });
|
||||
const ivrs = useQuery({ queryKey: ['ivrs'], queryFn: ivrsApi.list });
|
||||
const [kind, setKind] = useState<Kind>(value.type as Kind);
|
||||
const [number, setNumber] = useState(value.type === 'external' ? value.number : '');
|
||||
const [numberError, setNumberError] = useState<string>();
|
||||
const targetId = 'id' in value ? value.id : '';
|
||||
|
||||
const source = { user: exts, voicemail: exts, ring_group: groups, ivr: ivrs };
|
||||
const targets = (k: IdKind) =>
|
||||
(source[k].data ?? [])
|
||||
.map((x) => ({ id: x.id, label: `${x.number} ${x.name}` }))
|
||||
.filter((t) => t.id !== exclude);
|
||||
const all = useTargets();
|
||||
const targets = (k: IdKind) => (all[k] ?? []).filter((t) => t.id !== exclude);
|
||||
|
||||
function pickKind(k: Kind) {
|
||||
setKind(k);
|
||||
@@ -124,25 +120,38 @@ export function DestinationPicker({
|
||||
{numberError ?? error}
|
||||
</p>
|
||||
)}
|
||||
{isIdKind(kind) && source[kind].data && targets(kind).length === 0 && (
|
||||
{isIdKind(kind) && all[kind] && targets(kind).length === 0 && (
|
||||
<p className="text-xs text-zinc-500">Nothing to pick yet ({what}).</p>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
/** Client-side "Ring Dan Doe (101)" for destinations not saved yet (the API has its own). */
|
||||
export function useDescribeDestination() {
|
||||
/** Everything a destination can point at, per kind, as {id, label} ("101 Dan Doe"). */
|
||||
function useTargets() {
|
||||
const exts = useQuery({ queryKey: ['extensions'], queryFn: extensionsApi.list });
|
||||
const groups = useQuery({ queryKey: ['ring-groups'], queryFn: ringGroupsApi.list });
|
||||
const ivrs = useQuery({ queryKey: ['ivrs'], queryFn: ivrsApi.list });
|
||||
const tcs = useQuery({ queryKey: ['time-conditions'], queryFn: timeConditionsApi.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 {
|
||||
user: rows(exts),
|
||||
voicemail: rows(exts),
|
||||
ring_group: rows(groups),
|
||||
ivr: rows(ivrs),
|
||||
time_condition: rows(tcs),
|
||||
} satisfies Record<IdKind, unknown>;
|
||||
}
|
||||
|
||||
/** Client-side "Ring a user: 101 Dan Doe" for destinations not saved yet (the API has its own). */
|
||||
export function useDescribeDestination() {
|
||||
const targets = useTargets();
|
||||
return (d: Destination): string => {
|
||||
if (d.type === 'hangup') return 'Hang up';
|
||||
if (d.type === 'external') return `Call ${d.number}`;
|
||||
const rows = { user: exts, voicemail: exts, ring_group: groups, ivr: ivrs }[d.type as IdKind]
|
||||
?.data;
|
||||
const t = rows?.find((x) => x.id === d.id);
|
||||
const what = KINDS.find((k) => k.kind === d.type)?.label ?? d.type;
|
||||
return t ? `${what}: ${t.name} (${t.number})` : what;
|
||||
const t = targets[d.type as IdKind]?.find((x) => x.id === d.id);
|
||||
return t ? `${what}: ${t.label}` : what;
|
||||
};
|
||||
}
|
||||
@@ -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 { IvrsPage } from './pages/admin/IvrsPage.tsx';
|
||||
import { OfficeHoursPage } from './pages/admin/OfficeHoursPage.tsx';
|
||||
import { MePage } from './pages/user/MePage.tsx';
|
||||
import { SetupPage } from './pages/SetupPage.tsx';
|
||||
import './index.css';
|
||||
@@ -50,6 +51,8 @@ const router = createBrowserRouter([
|
||||
<RingGroupsPage />
|
||||
) : p.path === 'ivrs' ? (
|
||||
<IvrsPage />
|
||||
) : p.path === 'hours' ? (
|
||||
<OfficeHoursPage />
|
||||
) : (
|
||||
<ComingSoon label={p.label} milestone={p.milestone ?? ''} />
|
||||
),
|
||||
|
||||
@@ -18,7 +18,7 @@ export const ADMIN_PAGES = [
|
||||
{ path: 'ring-groups', label: 'Ring groups', milestone: null },
|
||||
{ path: 'queues', label: 'Queues', milestone: 'M8' },
|
||||
{ path: 'ivrs', label: 'Digital receptionist', milestone: null },
|
||||
{ path: 'hours', label: 'Office hours', milestone: 'M6' },
|
||||
{ path: 'hours', label: 'Office hours', milestone: null },
|
||||
{ path: 'conferences', label: 'Conferences', milestone: 'M8' },
|
||||
{ path: 'paging', label: 'Paging', milestone: 'M8' },
|
||||
{ path: 'sounds', label: 'Sounds & music', milestone: null },
|
||||
|
||||
@@ -0,0 +1,634 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Office hours (05 §2, 03 §6.10): when calls go one way or another. A weekly grid (click and drag
|
||||
// half hours), the exact ranges as a list, holidays, and force open / closed / automatic.
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
DAYS,
|
||||
rangesToSlots,
|
||||
slotsToRanges,
|
||||
SLOTS,
|
||||
TimeConditionCreateSchema,
|
||||
type Day,
|
||||
type Destination,
|
||||
type OpenRange,
|
||||
type OverrideState,
|
||||
type TimeConditionCreate,
|
||||
type TimeConditionDto,
|
||||
} from '@selfphoneng/shared';
|
||||
import { holidaysApi, settingsApi, timeConditionsApi } from '../../api/client.ts';
|
||||
import { DestinationPicker } from '../../components/DestinationPicker.tsx';
|
||||
import { ConfirmDialog, Dialog } from '../../components/Dialog.tsx';
|
||||
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
|
||||
|
||||
const DAY_NAME: Record<Day, string> = {
|
||||
mon: 'Mon',
|
||||
tue: 'Tue',
|
||||
wed: 'Wed',
|
||||
thu: 'Thu',
|
||||
fri: 'Fri',
|
||||
sat: 'Sat',
|
||||
sun: 'Sun',
|
||||
};
|
||||
const rangeText = (r: OpenRange) =>
|
||||
`${r.days.map((d) => DAY_NAME[d]).join(', ')} ${r.start}–${r.end === '24:00' ? 'midnight' : r.end}`;
|
||||
|
||||
export function OfficeHoursPage() {
|
||||
const [tab, setTab] = useState<'hours' | 'holidays'>('hours');
|
||||
const tabClass = (on: boolean) =>
|
||||
`rounded-md px-3 py-2 text-sm font-medium ${on ? 'bg-zinc-100 dark:bg-zinc-800' : 'hover:bg-zinc-100 dark:hover:bg-zinc-800'}`;
|
||||
return (
|
||||
<section className="flex flex-col gap-4">
|
||||
<h1 className="text-xl font-semibold">Office hours</h1>
|
||||
<div role="tablist" className="flex gap-1 border-b border-zinc-200 pb-2 dark:border-zinc-800">
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={tab === 'hours'}
|
||||
className={tabClass(tab === 'hours')}
|
||||
onClick={() => setTab('hours')}
|
||||
>
|
||||
Office hours
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={tab === 'holidays'}
|
||||
className={tabClass(tab === 'holidays')}
|
||||
onClick={() => setTab('holidays')}
|
||||
>
|
||||
Holidays
|
||||
</button>
|
||||
</div>
|
||||
{tab === 'hours' ? <HoursTab /> : <HolidaysTab />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusChip({ tc }: { tc: TimeConditionDto }) {
|
||||
const { now, reason } = tc.status;
|
||||
const look = {
|
||||
open: 'bg-green-100 text-green-900 dark:bg-green-900 dark:text-green-100',
|
||||
closed: 'bg-red-100 text-red-900 dark:bg-red-900 dark:text-red-100',
|
||||
holiday: 'bg-amber-100 text-amber-900 dark:bg-amber-900 dark:text-amber-100',
|
||||
}[now];
|
||||
const text = { open: 'Open now', closed: 'Closed now', holiday: 'Holiday today' }[now];
|
||||
return (
|
||||
<span role="status" className={`rounded-full px-3 py-1 text-sm font-semibold ${look}`}>
|
||||
{text}
|
||||
{reason === 'override' && ' (forced)'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function HoursTab() {
|
||||
const qc = useQueryClient();
|
||||
const list = useQuery({ queryKey: ['time-conditions'], queryFn: timeConditionsApi.list });
|
||||
const [editing, setEditing] = useState<TimeConditionDto | 'new' | null>(null);
|
||||
const [deleting, setDeleting] = useState<TimeConditionDto | null>(null);
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['time-conditions'] });
|
||||
const override = useMutation({
|
||||
mutationFn: ({ id, state }: { id: string; state: OverrideState }) =>
|
||||
timeConditionsApi.override(id, state),
|
||||
onSuccess: refresh,
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => timeConditionsApi.remove(id),
|
||||
onSuccess: async () => {
|
||||
setDeleting(null);
|
||||
await refresh();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<Button variant="primary" onClick={() => setEditing('new')}>
|
||||
Add office hours
|
||||
</Button>
|
||||
</div>
|
||||
{list.isError && <Alert>{list.error.message}</Alert>}
|
||||
{override.error && <Alert>{override.error.message}</Alert>}
|
||||
{list.data?.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">
|
||||
No office hours yet. Set your opening hours, then point your phone number at them: callers
|
||||
go one way when you're open and another when you're closed.
|
||||
</p>
|
||||
)}
|
||||
{list.data?.map((tc) => (
|
||||
<article
|
||||
key={tc.id}
|
||||
aria-label={tc.name}
|
||||
className="flex flex-col gap-3 rounded-lg border border-zinc-200 p-4 dark:border-zinc-800"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h2 className="flex-1 font-semibold">{tc.name}</h2>
|
||||
<StatusChip tc={tc} />
|
||||
</div>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{tc.openRanges.map(rangeText).join('; ') || 'Never open'} · {tc.timezone}
|
||||
{tc.useHolidays ? ' · closed on holidays' : ''}
|
||||
{tc.overrideDigit !== null &&
|
||||
` · from an admin's phone: *31${tc.overrideDigit} open, *32${tc.overrideDigit} closed, *33${tc.overrideDigit} automatic`}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['open', 'closed', 'auto'] as const).map((s) => {
|
||||
const active = s === 'auto' ? tc.overrideState === null : tc.overrideState === s;
|
||||
return (
|
||||
<Button
|
||||
key={s}
|
||||
aria-pressed={active}
|
||||
variant={active ? 'primary' : 'secondary'}
|
||||
disabled={override.isPending}
|
||||
onClick={() => override.mutate({ id: tc.id, state: s })}
|
||||
>
|
||||
{{ open: 'Force open', closed: 'Force closed', auto: 'Automatic' }[s]}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<span className="flex-1" />
|
||||
<Button onClick={() => setEditing(tc)}>Edit</Button>
|
||||
<Button variant="ghost" onClick={() => setDeleting(tc)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{editing && (
|
||||
<HoursForm
|
||||
tc={editing === 'new' ? null : editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={refresh}
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
title="Delete office hours"
|
||||
message={<p>Delete {deleting?.name}?</p>}
|
||||
confirmLabel="Delete"
|
||||
busy={remove.isPending}
|
||||
error={remove.error?.message}
|
||||
onCancel={() => {
|
||||
setDeleting(null);
|
||||
remove.reset();
|
||||
}}
|
||||
onConfirm={() => deleting && remove.mutate(deleting.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Click or drag across half-hour cells to paint them open or closed. Mouse and touch. */
|
||||
function WeekGrid({
|
||||
ranges,
|
||||
onChange,
|
||||
}: {
|
||||
ranges: OpenRange[];
|
||||
onChange: (r: OpenRange[]) => void;
|
||||
}) {
|
||||
const slots = rangesToSlots(ranges);
|
||||
const [paint, setPaint] = useState<boolean | null>(null);
|
||||
const set = (d: Day, i: number, v: boolean) => {
|
||||
if (slots[d][i] === v) return;
|
||||
const next = { ...slots, [d]: slots[d].map((x, j) => (j === i ? v : x)) };
|
||||
onChange(slotsToRanges(next));
|
||||
};
|
||||
return (
|
||||
// The list below is the keyboard and screen reader way to edit the same hours.
|
||||
<div
|
||||
aria-hidden
|
||||
className="max-w-full overflow-x-auto select-none"
|
||||
onPointerUp={() => setPaint(null)}
|
||||
onPointerLeave={() => setPaint(null)}
|
||||
>
|
||||
<div className="grid w-max grid-cols-[2.5rem_repeat(48,0.75rem)] gap-px text-[10px] text-zinc-500">
|
||||
<span />
|
||||
{Array.from({ length: SLOTS }, (_, i) => (
|
||||
<span key={i} className="text-center">
|
||||
{i % 4 === 0 ? i / 2 : ''}
|
||||
</span>
|
||||
))}
|
||||
{DAYS.map((d) => (
|
||||
<div key={d} className="contents">
|
||||
<span className="pr-1 text-right text-xs leading-5">{DAY_NAME[d]}</span>
|
||||
{slots[d].map((on, i) => (
|
||||
<span
|
||||
key={i}
|
||||
data-cell={`${d}-${i}`}
|
||||
className={`h-5 cursor-pointer touch-none rounded-sm ${on ? 'bg-green-500' : 'bg-zinc-200 dark:bg-zinc-800'}`}
|
||||
onPointerDown={(e) => {
|
||||
// Touch captures the pointer to this cell; let go so dragging paints the others.
|
||||
const el = e.target as Element;
|
||||
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
|
||||
setPaint(!on);
|
||||
set(d, i, !on);
|
||||
}}
|
||||
onPointerEnter={() => paint !== null && set(d, i, paint)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RangeList({
|
||||
ranges,
|
||||
onChange,
|
||||
}: {
|
||||
ranges: OpenRange[];
|
||||
onChange: (r: OpenRange[]) => void;
|
||||
}) {
|
||||
const [days, setDays] = useState<Day[]>(['mon', 'tue', 'wed', 'thu', 'fri']);
|
||||
const [start, setStart] = useState('08:00');
|
||||
const [end, setEnd] = useState('17:00');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<ul className="flex flex-col gap-1 text-sm">
|
||||
{ranges.map((r, i) => (
|
||||
<li key={i} className="flex items-center gap-2">
|
||||
<span className="flex-1">{rangeText(r)}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1"
|
||||
aria-label={`Remove ${rangeText(r)}`}
|
||||
onClick={() => onChange(ranges.filter((_, j) => j !== i))}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
{ranges.length === 0 && <li className="text-zinc-500">No open hours: always closed.</li>}
|
||||
</ul>
|
||||
<fieldset className="flex min-w-0 flex-wrap items-end gap-2 rounded-md border border-zinc-200 p-2 dark:border-zinc-800">
|
||||
<legend className="px-1 text-xs text-zinc-500">Add hours</legend>
|
||||
{DAYS.map((d) => (
|
||||
<label key={d} className="flex items-center gap-1 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={days.includes(d)}
|
||||
onChange={(e) =>
|
||||
setDays((ds) =>
|
||||
e.target.checked
|
||||
? DAYS.filter((x) => x === d || ds.includes(x))
|
||||
: ds.filter((x) => x !== d),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{DAY_NAME[d]}
|
||||
</label>
|
||||
))}
|
||||
<label className="flex w-32 flex-col text-xs">
|
||||
From
|
||||
<input
|
||||
type="time"
|
||||
className={inputClass}
|
||||
value={start}
|
||||
onChange={(e) => setStart(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex w-32 flex-col text-xs">
|
||||
Until
|
||||
<input
|
||||
type="time"
|
||||
className={inputClass}
|
||||
value={end}
|
||||
onChange={(e) => setEnd(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const until = end === '00:00' ? '24:00' : end;
|
||||
if (!days.length || !start || !until || start >= until) {
|
||||
setError(
|
||||
'Pick days, and an end after the start (add two ranges to go past midnight)',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
onChange([...ranges, { days, start, end: until }]);
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
{error && <p className="w-full text-xs text-red-600 dark:text-red-400">{error}</p>}
|
||||
</fieldset>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HoursForm({
|
||||
tc,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
tc: TimeConditionDto | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => Promise<unknown>;
|
||||
}) {
|
||||
const settings = useQuery({ queryKey: ['settings'], queryFn: settingsApi.get, enabled: !tc });
|
||||
const zones = Intl.supportedValuesOf?.('timeZone') ?? [];
|
||||
const [name, setName] = useState(tc?.name ?? '');
|
||||
const [timezone, setTimezone] = useState(tc?.timezone ?? '');
|
||||
const [ranges, setRanges] = useState<OpenRange[]>(
|
||||
tc?.openRanges ?? [{ days: ['mon', 'tue', 'wed', 'thu', 'fri'], start: '08:00', end: '17:00' }],
|
||||
);
|
||||
const [useHolidays, setUseHolidays] = useState(tc?.useHolidays ?? true);
|
||||
const [digit, setDigit] = useState<string>(tc?.overrideDigit ? String(tc.overrideDigit) : '');
|
||||
const [open, setOpen] = useState<Destination | null>(tc?.openDestination ?? { type: 'hangup' });
|
||||
const [closed, setClosed] = useState<Destination | null>(
|
||||
tc?.closedDestination ?? { type: 'hangup' },
|
||||
);
|
||||
const [holiday, setHoliday] = useState<Destination | null>(tc?.holidayDestination ?? null);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const tz = timezone || (tc ? '' : (settings.data?.['general.timezone'] ?? ''));
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (body: TimeConditionCreate) =>
|
||||
tc ? timeConditionsApi.update(tc.id, body) : timeConditionsApi.create(body),
|
||||
onSuccess: async () => {
|
||||
await onSaved();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
function submit() {
|
||||
const parsed = TimeConditionCreateSchema.safeParse({
|
||||
name,
|
||||
timezone: tz || undefined,
|
||||
openRanges: ranges,
|
||||
useHolidays,
|
||||
overrideDigit: digit ? Number(digit) : null,
|
||||
openDestination: open ?? undefined,
|
||||
closedDestination: closed ?? undefined,
|
||||
holidayDestination: holiday,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
setErrors(Object.fromEntries(parsed.error.issues.map((i) => [String(i.path[0]), i.message])));
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
save.mutate(parsed.data);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
drawer
|
||||
onClose={onClose}
|
||||
title={tc ? tc.name : 'New office hours'}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant="primary" onClick={submit} disabled={save.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Field label="Name" error={errors.name}>
|
||||
{(p) => (
|
||||
<input
|
||||
{...p}
|
||||
className={inputClass}
|
||||
value={name}
|
||||
placeholder="Business hours"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Time zone" error={errors.timezone}>
|
||||
{(p) => (
|
||||
<>
|
||||
<input
|
||||
{...p}
|
||||
className={inputClass}
|
||||
list="tz-list"
|
||||
value={tz}
|
||||
onChange={(e) => setTimezone(e.target.value)}
|
||||
/>
|
||||
<datalist id="tz-list">
|
||||
{zones.map((z) => (
|
||||
<option key={z} value={z} />
|
||||
))}
|
||||
</datalist>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
<fieldset className="flex min-w-0 flex-col gap-2">
|
||||
<legend className="mb-1 text-sm font-medium">Open hours</legend>
|
||||
<p className="text-xs text-zinc-500">Click or drag across the grid; green is open.</p>
|
||||
<WeekGrid ranges={ranges} onChange={setRanges} />
|
||||
{errors.openRanges && <Alert>{errors.openRanges}</Alert>}
|
||||
<RangeList ranges={ranges} onChange={setRanges} />
|
||||
</fieldset>
|
||||
<DestinationPicker
|
||||
label="When open"
|
||||
value={open ?? { type: 'hangup' }}
|
||||
onChange={setOpen}
|
||||
error={errors.openDestination}
|
||||
exclude={tc?.id}
|
||||
/>
|
||||
<DestinationPicker
|
||||
label="When closed"
|
||||
value={closed ?? { type: 'hangup' }}
|
||||
onChange={setClosed}
|
||||
error={errors.closedDestination}
|
||||
exclude={tc?.id}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useHolidays}
|
||||
onChange={(e) => setUseHolidays(e.target.checked)}
|
||||
/>
|
||||
Closed on holidays (the Holidays tab)
|
||||
</label>
|
||||
{useHolidays && (
|
||||
<>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={holiday !== null}
|
||||
onChange={(e) => setHoliday(e.target.checked ? { type: 'hangup' } : null)}
|
||||
/>
|
||||
Holidays go somewhere else than "When closed"
|
||||
</label>
|
||||
{holiday !== null && (
|
||||
<DestinationPicker
|
||||
label="On holidays"
|
||||
value={holiday}
|
||||
onChange={setHoliday}
|
||||
exclude={tc?.id}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Field
|
||||
label="Phone override key"
|
||||
hint="An admin dials *31N to force open, *32N to force closed, *33N for automatic"
|
||||
>
|
||||
{(p) => (
|
||||
<select
|
||||
{...p}
|
||||
className={inputClass}
|
||||
value={digit}
|
||||
onChange={(e) => setDigit(e.target.value)}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n} (*31{n} / *32{n} / *33{n})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
{save.error && <Alert>{save.error.message}</Alert>}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function HolidaysTab() {
|
||||
const qc = useQueryClient();
|
||||
const list = useQuery({ queryKey: ['holidays'], queryFn: holidaysApi.list });
|
||||
const refresh = () =>
|
||||
Promise.all([
|
||||
qc.invalidateQueries({ queryKey: ['holidays'] }),
|
||||
qc.invalidateQueries({ queryKey: ['time-conditions'] }),
|
||||
]);
|
||||
const thisYear = new Date().getFullYear();
|
||||
const [year, setYear] = useState(thisYear);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
recurringYearly: false,
|
||||
});
|
||||
const add = useMutation({
|
||||
mutationFn: () => holidaysApi.create({ ...form, endDate: form.endDate || form.startDate }),
|
||||
onSuccess: async () => {
|
||||
setForm({ name: '', startDate: '', endDate: '', recurringYearly: false });
|
||||
await refresh();
|
||||
},
|
||||
});
|
||||
const importUs = useMutation({
|
||||
mutationFn: () => holidaysApi.importUsFederal(year),
|
||||
onSuccess: refresh,
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => holidaysApi.remove(id),
|
||||
onSuccess: refresh,
|
||||
});
|
||||
const fmt = (d: string) =>
|
||||
new Date(`${d}T12:00:00`).toLocaleDateString(undefined, { dateStyle: 'medium' });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className="sr-only" htmlFor="us-year">
|
||||
Year
|
||||
</label>
|
||||
<select
|
||||
id="us-year"
|
||||
className={`${inputClass} w-28`}
|
||||
value={year}
|
||||
onChange={(e) => setYear(Number(e.target.value))}
|
||||
>
|
||||
{[thisYear, thisYear + 1].map((y) => (
|
||||
<option key={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
<Button onClick={() => importUs.mutate()} disabled={importUs.isPending}>
|
||||
Add US federal holidays for {year}
|
||||
</Button>
|
||||
{importUs.data && (
|
||||
<span role="status" className="text-sm text-zinc-500">
|
||||
Added {importUs.data.added}.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{list.data?.length === 0 && <p className="text-sm text-zinc-500">No holidays yet.</p>}
|
||||
{list.data && list.data.length > 0 && (
|
||||
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||
{list.data.map((h) => (
|
||||
<li key={h.id} className="flex flex-wrap items-center gap-x-3 px-3 py-2 text-sm">
|
||||
<span className="flex-1 font-medium">{h.name}</span>
|
||||
<span className="text-zinc-500">
|
||||
{fmt(h.startDate)}
|
||||
{h.endDate !== h.startDate && ` – ${fmt(h.endDate)}`}
|
||||
{h.recurringYearly && ' · every year'}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label={`Remove ${h.name}`}
|
||||
onClick={() => remove.mutate(h.id)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<form
|
||||
className="flex flex-wrap items-end gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
add.mutate();
|
||||
}}
|
||||
>
|
||||
<Field label="Holiday">
|
||||
{(p) => (
|
||||
<input
|
||||
{...p}
|
||||
className={inputClass}
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="From">
|
||||
{(p) => (
|
||||
<input
|
||||
{...p}
|
||||
type="date"
|
||||
className={inputClass}
|
||||
value={form.startDate}
|
||||
onChange={(e) => setForm({ ...form, startDate: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Until (optional)">
|
||||
{(p) => (
|
||||
<input
|
||||
{...p}
|
||||
type="date"
|
||||
className={inputClass}
|
||||
value={form.endDate}
|
||||
onChange={(e) => setForm({ ...form, endDate: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<label className="flex items-center gap-2 pb-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.recurringYearly}
|
||||
onChange={(e) => setForm({ ...form, recurringYearly: e.target.checked })}
|
||||
/>
|
||||
Every year
|
||||
</label>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={!form.name || !form.startDate || add.isPending}
|
||||
>
|
||||
Add holiday
|
||||
</Button>
|
||||
</form>
|
||||
{(add.error ?? importUs.error ?? remove.error) && (
|
||||
<Alert>{(add.error ?? importUs.error ?? remove.error)!.message}</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -663,6 +663,18 @@ Holiday rules:
|
||||
Override codes use `time_conditions.override_digit` (02). Feature codes `*31N` force open,
|
||||
`*32N` force closed, `*33N` automatic (§6.13).
|
||||
|
||||
As built (M6-T4):
|
||||
- A range's `end` is exclusive ("closed at 17:00") but `GotoIfTime`'s end minute is inclusive, so
|
||||
ranges render one minute short: `08:00-16:59`; `24:00` (end of day) renders `23:59`.
|
||||
- Holiday days are deduplicated and sorted by month/day; a range over New Year works.
|
||||
- `[sp-tc-override]` is always rendered, ending with `exten => _*3[123]X` → `ss-noservice` for
|
||||
digits no office hours use. `*33N` plays `de-activated`.
|
||||
- The api's "open now" (`timeConditionStatus` in `packages/shared`) makes the same decision.
|
||||
- Holidays roll over by re-rendering when the company date changes (a 10-minute check in
|
||||
`index.ts`) instead of a croner job at 00:05; M11-T4 can move it into the jobs.
|
||||
- Golden cases `holidays` and `ivr-business-hours`; `scripts/e2e/m6-t4.mjs` checks force
|
||||
open/closed routing, `*321`/`*331` from an admin phone, and refusal for a non-admin.
|
||||
|
||||
### 6.11 Announcements, conferences, paging
|
||||
|
||||
```ini
|
||||
|
||||
@@ -233,6 +233,8 @@ On every AMI connect, PostgreSQL wins:
|
||||
5. For each time condition with `override_state`: `DBPut SP TCOVR/<id> <state>`.
|
||||
|
||||
Keep one function per key type (`setDnd(ext, on)`, etc.). Routes call these after writing PostgreSQL.
|
||||
M6-T4 implements the sync and `setTcOverride`; DND/forwards (M6-T7), blocks (M6-T8) and queue
|
||||
pauses (M8) add their keys.
|
||||
|
||||
### 5.6 Voicemail watcher (`services/voicemail.ts`)
|
||||
|
||||
|
||||
@@ -14,3 +14,4 @@ export * from './voicemail.ts';
|
||||
export * from './sounds.ts';
|
||||
export * from './ringgroups.ts';
|
||||
export * from './ivrs.ts';
|
||||
export * from './timeconditions.ts';
|
||||
@@ -0,0 +1,85 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isHoliday, rangesToSlots, slotsToRanges, timeConditionStatus } from './timeconditions.ts';
|
||||
|
||||
const tc = {
|
||||
timezone: 'America/Chicago',
|
||||
openRanges: [
|
||||
{ days: ['mon', 'tue', 'wed', 'thu', 'fri'] as const, start: '08:00', end: '17:00' },
|
||||
].map((r) => ({ ...r, days: [...r.days] })),
|
||||
useHolidays: true,
|
||||
overrideState: null,
|
||||
};
|
||||
// 2026-10-19 is a Monday; Chicago is UTC-5 (CDT) then.
|
||||
const at = (localHHMM: string, date = '2026-10-19') => new Date(`${date}T${localHHMM}:00-05:00`);
|
||||
|
||||
describe('timeConditionStatus', () => {
|
||||
it('open inside the hours, closed at the end minute and on weekends', () => {
|
||||
expect(timeConditionStatus(tc, [], at('08:00')).now).toBe('open');
|
||||
expect(timeConditionStatus(tc, [], at('16:59')).now).toBe('open');
|
||||
expect(timeConditionStatus(tc, [], at('17:00')).now).toBe('closed');
|
||||
expect(timeConditionStatus(tc, [], at('07:59')).now).toBe('closed');
|
||||
expect(timeConditionStatus(tc, [], at('12:00', '2026-10-18')).now).toBe('closed'); // Sunday
|
||||
});
|
||||
|
||||
it('uses the condition time zone, not the server one', () => {
|
||||
const ny = { ...tc, timezone: 'America/New_York' }; // 08:30 Chicago = 09:30 New York
|
||||
expect(timeConditionStatus(ny, [], at('07:30')).now).toBe('open');
|
||||
expect(timeConditionStatus(tc, [], at('07:30')).now).toBe('closed');
|
||||
});
|
||||
|
||||
it('holidays beat hours; overrides beat everything', () => {
|
||||
const h = [{ startDate: '2026-10-19', endDate: '2026-10-19', recurringYearly: false }];
|
||||
expect(timeConditionStatus(tc, h, at('10:00'))).toEqual({ now: 'holiday', reason: 'holiday' });
|
||||
expect(timeConditionStatus({ ...tc, useHolidays: false }, h, at('10:00')).now).toBe('open');
|
||||
expect(timeConditionStatus({ ...tc, overrideState: 'closed' }, [], at('10:00'))).toEqual({
|
||||
now: 'closed',
|
||||
reason: 'override',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isHoliday', () => {
|
||||
it('one-off ranges and yearly ones, including over New Year', () => {
|
||||
const range = { startDate: '2026-12-24', endDate: '2026-12-26', recurringYearly: false };
|
||||
expect(isHoliday(range, '2026-12-25')).toBe(true);
|
||||
expect(isHoliday(range, '2027-12-25')).toBe(false);
|
||||
const xmas = { startDate: '2020-12-25', endDate: '2020-12-25', recurringYearly: true };
|
||||
expect(isHoliday(xmas, '2031-12-25')).toBe(true);
|
||||
const nye = { startDate: '2020-12-31', endDate: '2021-01-01', recurringYearly: true };
|
||||
expect(isHoliday(nye, '2030-01-01')).toBe(true);
|
||||
expect(isHoliday(nye, '2030-01-02')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('weekly grid', () => {
|
||||
it('round-trips ranges, grouping days with the same hours', () => {
|
||||
const ranges = [
|
||||
{
|
||||
days: ['mon' as const, 'tue' as const, 'wed' as const, 'thu' as const, 'fri' as const],
|
||||
start: '08:00',
|
||||
end: '12:00',
|
||||
},
|
||||
{
|
||||
days: ['mon' as const, 'tue' as const, 'wed' as const, 'thu' as const, 'fri' as const],
|
||||
start: '13:00',
|
||||
end: '17:30',
|
||||
},
|
||||
{ days: ['sat' as const], start: '09:00', end: '24:00' },
|
||||
];
|
||||
const slots = rangesToSlots(ranges);
|
||||
expect(slots.mon[16]).toBe(true); // 08:00
|
||||
expect(slots.mon[24]).toBe(false); // 12:00 (lunch)
|
||||
expect(slots.sat[47]).toBe(true); // 23:30
|
||||
expect(slotsToRanges(slots)).toEqual(ranges);
|
||||
});
|
||||
|
||||
it('a day painted differently splits from the group', () => {
|
||||
const slots = rangesToSlots([{ days: ['mon', 'tue'], start: '08:00', end: '17:00' }]);
|
||||
slots.tue[34] = true; // 17:00-17:30 added on Tuesday only
|
||||
expect(slotsToRanges(slots)).toEqual([
|
||||
{ days: ['mon'], start: '08:00', end: '17:00' },
|
||||
{ days: ['tue'], start: '08:00', end: '17:30' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Office hours / time conditions and holidays (02 time_conditions, holidays; 03 §6.10).
|
||||
import { z } from 'zod';
|
||||
import { DestinationSchema, type Destination } from './destination.ts';
|
||||
|
||||
export const DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] as const;
|
||||
export type Day = (typeof DAYS)[number];
|
||||
|
||||
/** "08:00".."24:00"; the end is exclusive (closed at 17:00 means open until 16:59). */
|
||||
const Time = z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$|^24:00$/, 'Like 08:30');
|
||||
|
||||
export const OpenRangeSchema = z
|
||||
.object({
|
||||
days: z.array(z.enum(DAYS)).min(1, 'Pick at least one day'),
|
||||
start: Time,
|
||||
end: Time,
|
||||
})
|
||||
// Ranges can't cross midnight: add two (03 §6.10).
|
||||
.refine((r) => r.start < r.end, 'The end must be after the start');
|
||||
export type OpenRange = z.infer<typeof OpenRangeSchema>;
|
||||
|
||||
export const OVERRIDE_STATES = ['open', 'closed', 'auto'] as const;
|
||||
export type OverrideState = (typeof OVERRIDE_STATES)[number];
|
||||
|
||||
const TimeConditionFields = z.object({
|
||||
name: z.string().trim().min(1, 'Required').max(40),
|
||||
timezone: z
|
||||
.string()
|
||||
.regex(/^[A-Za-z][A-Za-z0-9_+/-]*$/, 'A time zone like America/Chicago')
|
||||
.refine((tz) => {
|
||||
try {
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: tz });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, 'Unknown time zone'),
|
||||
openRanges: z.array(OpenRangeSchema).max(30),
|
||||
useHolidays: z.boolean(),
|
||||
/** N in the feature codes *31N (force open), *32N (force closed), *33N (automatic). */
|
||||
overrideDigit: z.number().int().min(1).max(9).nullable(),
|
||||
openDestination: DestinationSchema,
|
||||
closedDestination: DestinationSchema,
|
||||
/** null = the closed destination. */
|
||||
holidayDestination: DestinationSchema.nullable(),
|
||||
});
|
||||
|
||||
export const TimeConditionCreateSchema = TimeConditionFields.partial().required({
|
||||
name: true,
|
||||
openDestination: true,
|
||||
closedDestination: true,
|
||||
});
|
||||
export const TimeConditionUpdateSchema = TimeConditionFields.partial();
|
||||
export type TimeConditionCreate = z.input<typeof TimeConditionCreateSchema>;
|
||||
export type TimeConditionUpdate = z.input<typeof TimeConditionUpdateSchema>;
|
||||
export const OverrideBodySchema = z.object({ state: z.enum(OVERRIDE_STATES) });
|
||||
|
||||
export type TimeConditionStatus = {
|
||||
/** What callers get right now. */
|
||||
now: 'open' | 'closed' | 'holiday';
|
||||
/** Why: forced by someone, a holiday, or the weekly hours. */
|
||||
reason: 'override' | 'holiday' | 'hours';
|
||||
};
|
||||
|
||||
export type TimeConditionDto = z.output<typeof TimeConditionFields> & {
|
||||
id: string;
|
||||
openDestination: Destination;
|
||||
closedDestination: Destination;
|
||||
holidayDestination: Destination | null;
|
||||
/** null = automatic. */
|
||||
overrideState: 'open' | 'closed' | null;
|
||||
status: TimeConditionStatus;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const Day = z.iso.date();
|
||||
export const HolidaySchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1, 'Required').max(60),
|
||||
startDate: Day,
|
||||
endDate: Day,
|
||||
/** Same month and day every year (e.g. Christmas). */
|
||||
recurringYearly: z.boolean().default(false),
|
||||
})
|
||||
.refine((h) => h.startDate <= h.endDate, {
|
||||
message: 'The end is before the start',
|
||||
path: ['endDate'],
|
||||
})
|
||||
.refine((h) => (Date.parse(h.endDate) - Date.parse(h.startDate)) / 86_400_000 < 31, {
|
||||
message: 'At most 31 days',
|
||||
path: ['endDate'],
|
||||
});
|
||||
export type HolidayInput = z.input<typeof HolidaySchema>;
|
||||
export type HolidayDto = z.output<typeof HolidaySchema> & { id: string };
|
||||
|
||||
/** Day of week and "HH:MM" of `at` in `timezone`. */
|
||||
export function localTime(at: Date, timezone: string): { day: Day; time: string; date: string } {
|
||||
const parts = Object.fromEntries(
|
||||
new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
})
|
||||
.formatToParts(at)
|
||||
.map((p) => [p.type, p.value]),
|
||||
);
|
||||
return {
|
||||
day: parts.weekday!.toLowerCase().slice(0, 3) as Day,
|
||||
time: `${parts.hour}:${parts.minute}`,
|
||||
date: `${parts.year}-${parts.month}-${parts.day}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** True when `date` (YYYY-MM-DD) falls on the holiday (recurring ones ignore the year). */
|
||||
export function isHoliday(
|
||||
h: Pick<HolidayDto, 'startDate' | 'endDate' | 'recurringYearly'>,
|
||||
date: string,
|
||||
) {
|
||||
if (!h.recurringYearly) return h.startDate <= date && date <= h.endDate;
|
||||
const md = date.slice(5);
|
||||
const [s, e] = [h.startDate.slice(5), h.endDate.slice(5)];
|
||||
return s <= e ? s <= md && md <= e : md >= s || md <= e; // a range over New Year
|
||||
}
|
||||
|
||||
/** The same decision the dialplan makes (03 §6.10): override, then holidays, then hours. */
|
||||
export function timeConditionStatus(
|
||||
tc: Pick<TimeConditionDto, 'timezone' | 'openRanges' | 'useHolidays' | 'overrideState'>,
|
||||
holidays: Pick<HolidayDto, 'startDate' | 'endDate' | 'recurringYearly'>[],
|
||||
at = new Date(),
|
||||
): TimeConditionStatus {
|
||||
if (tc.overrideState) return { now: tc.overrideState, reason: 'override' };
|
||||
const { day, time, date } = localTime(at, tc.timezone);
|
||||
if (tc.useHolidays && holidays.some((h) => isHoliday(h, date)))
|
||||
return { now: 'holiday', reason: 'holiday' };
|
||||
const open = tc.openRanges.some((r) => r.days.includes(day) && r.start <= time && time < r.end);
|
||||
return { now: open ? 'open' : 'closed', reason: 'hours' };
|
||||
}
|
||||
|
||||
/** The weekly grid edits half-hour slots: slots[day][0..47], slot i = i*30 min. */
|
||||
export const SLOTS = 48;
|
||||
const toSlot = (t: string) => {
|
||||
const [h, m] = t.split(':').map(Number) as [number, number];
|
||||
return (h * 60 + m) / 30;
|
||||
};
|
||||
const ofSlot = (i: number) =>
|
||||
`${String(Math.floor(i / 2)).padStart(2, '0')}:${i % 2 ? '30' : '00'}`;
|
||||
|
||||
/** Ranges → grid. Times between half hours are rounded to the slot they start in. */
|
||||
export function rangesToSlots(ranges: OpenRange[]): Record<Day, boolean[]> {
|
||||
const out = Object.fromEntries(DAYS.map((d) => [d, Array<boolean>(SLOTS).fill(false)])) as Record<
|
||||
Day,
|
||||
boolean[]
|
||||
>;
|
||||
for (const r of ranges)
|
||||
for (const d of r.days)
|
||||
for (let i = Math.floor(toSlot(r.start)); i < Math.ceil(toSlot(r.end)); i++) out[d][i] = true;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Grid → ranges: one range per run of open slots, days with the same run grouped together. */
|
||||
export function slotsToRanges(slots: Record<Day, boolean[]>): OpenRange[] {
|
||||
const byRun = new Map<string, Day[]>();
|
||||
for (const d of DAYS) {
|
||||
for (let i = 0; i < SLOTS;) {
|
||||
if (!slots[d][i]) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
let j = i;
|
||||
while (j < SLOTS && slots[d][j]) j++;
|
||||
const key = `${ofSlot(i)}-${ofSlot(j)}`;
|
||||
byRun.set(key, [...(byRun.get(key) ?? []), d]);
|
||||
i = j;
|
||||
}
|
||||
}
|
||||
return [...byRun.entries()]
|
||||
.map(([run, days]) => {
|
||||
const [start, end] = run.split('-') as [string, string];
|
||||
return { days, start, end };
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
DAYS.indexOf(a.days[0]!) - DAYS.indexOf(b.days[0]!) || a.start.localeCompare(b.start),
|
||||
);
|
||||
}
|
||||
@@ -88,3 +88,9 @@ switches the group to "one at a time" with 5 s each and checks that 190 rings al
|
||||
greeting through the form's SoundPicker, sends key 1 to 193 and key 2 to 194, and then a Local
|
||||
channel sending DTMF presses 1, 2 and a wrong key (5): 193 or 194 rings, or "not a valid choice"
|
||||
plays and then the greeting again.
|
||||
|
||||
`m6-t4.mjs` builds office hours in the UI. It paints Saturday 10–11 on the grid and checks that
|
||||
Force closed / Force open route a call to 196 / 195 and set or clear AstDB `SP/TCOVR/<id>`. `*321`
|
||||
and `*331` from the admin's own phone (197) must reach the database and the page, `*311` from a
|
||||
non-admin phone must get "no service", and the 2026 US federal holiday import must reach the
|
||||
dialplan.
|
||||
@@ -0,0 +1,228 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M6-T4 in a browser + real calls: office hours built in the UI (open → 195, closed → 196, override
|
||||
// key 1); Force closed / Force open route calls and set AstDB; *311 from an admin's phone (197)
|
||||
// forces open and the page follows; the US federal holidays import.
|
||||
// Runs on the m2-live setup after m1-t4.mjs.
|
||||
// Usage: node scripts/e2e/m6-t4.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { launch } from './cdp.mjs';
|
||||
import { startPhones } from './phones.mjs';
|
||||
|
||||
const NET = 'sp-live';
|
||||
const AST = 'sp-live-ast';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const cli = (cmd) =>
|
||||
spawnSync('docker', ['exec', AST, 'asterisk', '-rx', cmd], { encoding: 'utf8' }).stdout ?? '';
|
||||
const astLog = () => spawnSync('docker', ['logs', AST], { encoding: 'utf8' }).stdout ?? '';
|
||||
const [base, email, pw, shotsDir] = process.argv.slice(2);
|
||||
|
||||
const login = await fetch(`${base}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng' },
|
||||
body: JSON.stringify({ email, password: pw }),
|
||||
});
|
||||
const cookie = login.headers
|
||||
.getSetCookie()
|
||||
.map((c) => c.split(';')[0])
|
||||
.join('; ');
|
||||
const api = async (method, url, body) => {
|
||||
const r = await fetch(`${base}${url}`, {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng', cookie },
|
||||
body: body && JSON.stringify(body),
|
||||
});
|
||||
const json = await r.json();
|
||||
assert.ok(r.ok, `${method} ${url}: ${r.status} ${JSON.stringify(json)}`);
|
||||
return json;
|
||||
};
|
||||
const desk = async (number, name, userId) => {
|
||||
const e = await api('POST', '/api/extensions', { number, name, ...(userId ? { userId } : {}) });
|
||||
return (await api('GET', `/api/extensions/${e.id}/sip-credentials`)).password;
|
||||
};
|
||||
const admin = (await api('GET', '/api/users')).items.find((u) => u.email === email);
|
||||
const phones = startPhones({
|
||||
name: 'sp-live-phones-m6t4',
|
||||
net: NET,
|
||||
ast: AST,
|
||||
phones: [
|
||||
{ ext: '195', password: await desk('195', 'Olive Open'), answer: 'auto' },
|
||||
{ ext: '196', password: await desk('196', 'Cliff Closed'), answer: 'auto', toneHz: 660 },
|
||||
// The admin's own desk phone: allowed to use the override codes.
|
||||
...(admin.extension
|
||||
? []
|
||||
: [{ ext: '197', password: await desk('197', 'Admin Desk', admin.id), answer: 'auto' }]),
|
||||
],
|
||||
});
|
||||
|
||||
const waitUntil = async (fn, what, ms = 20_000) => {
|
||||
const end = Date.now() + ms;
|
||||
while (Date.now() < end) {
|
||||
if (await fn()) return;
|
||||
await sleep(200);
|
||||
}
|
||||
throw new Error(`timeout waiting for: ${what}`);
|
||||
};
|
||||
const pick = (label, text) =>
|
||||
p.evaluate(`(() => {
|
||||
const s = [...document.querySelectorAll('dialog[open] select')].find(s => s.getAttribute('aria-label') === ${JSON.stringify(label)} || s.labels?.[0]?.textContent === ${JSON.stringify(label)});
|
||||
if (!s) throw new Error('no select ' + ${JSON.stringify(label)});
|
||||
const o = [...s.options].find(o => o.textContent.includes(${JSON.stringify(text)}));
|
||||
if (!o) throw new Error('no option ' + ${JSON.stringify(text)});
|
||||
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value').set.call(s, o.value);
|
||||
s.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
})()`);
|
||||
const hangupAll = async () => {
|
||||
cli('channel request hangup all');
|
||||
await waitUntil(() => !/(PJSIP|Local)\//.test(cli('core show channels concise')), 'calls ended');
|
||||
await sleep(500);
|
||||
};
|
||||
/** A caller dropped straight into the office hours context; returns who got rung. */
|
||||
async function callHours(id) {
|
||||
const at = astLog().length;
|
||||
cli(`channel originate Local/s@sp-tc-${id} application Wait 20`);
|
||||
let who = null;
|
||||
await waitUntil(() => {
|
||||
who = /Called PJSIP\/(19[56])\//.exec(astLog().slice(at))?.[1] ?? null;
|
||||
return who;
|
||||
}, 'someone rings');
|
||||
await hangupAll();
|
||||
return who;
|
||||
}
|
||||
const chip = () => p.evaluate(`document.querySelector('article [role=status]')?.textContent`);
|
||||
|
||||
const p = await launch({ shotsDir });
|
||||
try {
|
||||
await waitUntil(
|
||||
() =>
|
||||
['195', '196', '197'].every((n) =>
|
||||
new RegExp(`${n}/sip:.*Avail`).test(cli('pjsip show contacts')),
|
||||
),
|
||||
'phones registered',
|
||||
180_000,
|
||||
);
|
||||
await p.goto(`${base}/login`);
|
||||
await p.waitFor(`!!document.querySelector('input[type=email]')`);
|
||||
await p.fill('Email', email);
|
||||
await p.fill('Password', pw);
|
||||
await p.click('Sign in');
|
||||
await p.waitFor(`location.pathname === '/admin'`);
|
||||
await p.click('Office hours');
|
||||
await p.click('Add office hours');
|
||||
await p.waitFor(`!!document.querySelector('dialog[open]')`);
|
||||
await p.fill('Name', 'Business hours', 'dialog[open]');
|
||||
await p.waitFor(
|
||||
`[...document.querySelectorAll('dialog[open] label')].find(l => l.textContent === 'Time zone')?.control.value === 'America/Chicago'`,
|
||||
'company time zone filled in',
|
||||
);
|
||||
// Paint Saturday 10:00–11:00 on the grid (two half-hour cells, a drag).
|
||||
// One event per step, like a real drag across frames (React re-renders in between).
|
||||
const pointer = (i, type) =>
|
||||
p.evaluate(
|
||||
`document.querySelector('[data-cell="sat-${i}"]').dispatchEvent(new PointerEvent('${type}', { bubbles: true })), true`,
|
||||
);
|
||||
await pointer(20, 'pointerdown');
|
||||
await sleep(100);
|
||||
await pointer(21, 'pointerover'); // React builds onPointerEnter from pointerover
|
||||
await sleep(100);
|
||||
await pointer(21, 'pointerup');
|
||||
await p.waitFor(
|
||||
`document.querySelector('dialog[open]').innerText.includes('Sat 10:00–11:00')`,
|
||||
'grid → ranges',
|
||||
);
|
||||
await pick('When open: type', 'Ring a user');
|
||||
await pick('When open: user', 'Olive Open');
|
||||
await pick('When closed: type', 'Ring a user');
|
||||
await pick('When closed: user', 'Cliff Closed');
|
||||
await pick('Phone override key', '1 (');
|
||||
await p.shot('m6-t4-01-form');
|
||||
await p.click('Save', 'dialog[open]');
|
||||
await p.waitFor(
|
||||
`!document.querySelector('dialog[open]') && !!document.querySelector('article')`,
|
||||
'saved',
|
||||
);
|
||||
const tc = (await api('GET', '/api/time-conditions')).items[0];
|
||||
assert.deepEqual(tc.openRanges, [
|
||||
{ days: ['mon', 'tue', 'wed', 'thu', 'fri'], start: '08:00', end: '17:00' },
|
||||
{ days: ['sat'], start: '10:00', end: '11:00' },
|
||||
]);
|
||||
await waitUntil(() => cli(`dialplan show s@sp-tc-${tc.id}`).includes('GotoIfTime'), 'dialplan');
|
||||
console.log(`office hours saved: ${await chip()} (Mon–Fri 08–17, Sat 10–11)`);
|
||||
|
||||
await p.click('Force closed');
|
||||
await p.waitFor(
|
||||
`document.querySelector('article [role=status]')?.textContent === 'Closed now (forced)'`,
|
||||
);
|
||||
assert.match(cli('database show SP'), new RegExp(`/SP/TCOVR/${tc.id}\\s*:\\s*closed`));
|
||||
assert.equal(await callHours(tc.id), '196', 'forced closed → the closed destination');
|
||||
console.log('Force closed: AstDB set, a call goes to 196');
|
||||
|
||||
await p.click('Force open');
|
||||
await p.waitFor(
|
||||
`document.querySelector('article [role=status]')?.textContent === 'Open now (forced)'`,
|
||||
);
|
||||
assert.equal(await callHours(tc.id), '195', 'forced open → the open destination');
|
||||
console.log('Force open: a call goes to 195');
|
||||
|
||||
await p.click('Automatic');
|
||||
await p.waitFor(
|
||||
`!document.querySelector('article [role=status]')?.textContent.includes('forced')`,
|
||||
);
|
||||
assert.doesNotMatch(cli('database show SP'), /TCOVR/);
|
||||
console.log(`Automatic: AstDB key gone, ${await chip()}`);
|
||||
|
||||
// *321 from the admin's desk phone forces closed; the page follows after a refresh.
|
||||
cli('channel originate PJSIP/197 extension *321@sp-from-ext');
|
||||
await waitUntil(
|
||||
async () => (await api('GET', `/api/time-conditions/${tc.id}`)).overrideState === 'closed',
|
||||
'*321 reached the database',
|
||||
);
|
||||
await p.goto(`${base}/admin/hours`);
|
||||
await p.waitFor(
|
||||
`document.querySelector('article [role=status]')?.textContent === 'Closed now (forced)'`,
|
||||
);
|
||||
console.log('*321 from the admin phone: forced closed in Asterisk and in the page');
|
||||
await hangupAll();
|
||||
cli('channel originate PJSIP/197 extension *331@sp-from-ext');
|
||||
await waitUntil(
|
||||
async () => (await api('GET', `/api/time-conditions/${tc.id}`)).overrideState === null,
|
||||
'*331 back to automatic',
|
||||
);
|
||||
await hangupAll();
|
||||
|
||||
// A non-admin phone is refused.
|
||||
const before = astLog().length;
|
||||
cli('channel originate PJSIP/195 extension *311@sp-from-ext');
|
||||
await waitUntil(() => /Playing 'ss-noservice/.test(astLog().slice(before)), 'non-admin refused');
|
||||
assert.equal((await api('GET', `/api/time-conditions/${tc.id}`)).overrideState, null);
|
||||
console.log('*311 from a non-admin phone: "no service", nothing changed');
|
||||
await hangupAll();
|
||||
|
||||
await p.click('Holidays');
|
||||
await p.evaluate(
|
||||
`[...document.querySelectorAll('button')].find(b => b.textContent.startsWith('Add US federal holidays for 2026'))?.click() ?? (() => { const s = document.getElementById('us-year'); Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value').set.call(s, '2026'); s.dispatchEvent(new Event('change', { bubbles: true })); })(), true`,
|
||||
);
|
||||
await p.waitFor(
|
||||
`[...document.querySelectorAll('button')].some(b => b.textContent === 'Add US federal holidays for 2026')`,
|
||||
);
|
||||
await p.click('Add US federal holidays for 2026');
|
||||
await p.waitFor(`document.body.innerText.includes('Added 11.')`, 'imported');
|
||||
await p.waitFor(`document.body.innerText.includes('Thanksgiving Day')`);
|
||||
await p.shot('m6-t4-02-holidays');
|
||||
await waitUntil(
|
||||
() => /26,nov/.test(cli(`dialplan show s@sp-tc-${tc.id}`)),
|
||||
'holidays in the dialplan',
|
||||
);
|
||||
console.log('US federal holidays 2026 imported and in the dialplan');
|
||||
|
||||
console.log('M6-T4 (office hours): PASS');
|
||||
} catch (e) {
|
||||
await p.shot('m6-t4-fail').catch(() => {});
|
||||
console.log(`FAIL: ${e.message}`);
|
||||
console.log(`browser errors: ${JSON.stringify(p.consoleErrors)}`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await p.close();
|
||||
phones.stop();
|
||||
}
|
||||
Reference in new issue
Block a user