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:
iamdoubz committed 2026-10-18 10:56:28 -05:00
1 parent 15624f7b77
commit 108547046c
57 files changed
+3221 -29

No files matched your search

+2
View File
@@ -24,6 +24,7 @@ import { meRoutes } from './routes/me.ts';
import { soundRoutes } from './routes/sounds.ts';
import { ringGroupRoutes } from './routes/ringgroups.ts';
import { 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.
@@ -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" }]
}
@@ -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()
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -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
@@ -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()
@@ -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
@@ -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)
@@ -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)
@@ -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)
@@ -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()
+5 -1
View File
@@ -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()';
}
}
+7 -1
View File
@@ -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');
}
+15
View File
@@ -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',
+2
View File
@@ -23,6 +23,8 @@ describe('golden', () => {
'voipms-basic',
'ringgroups',
'ivr',
'holidays',
'ivr-business-hours',
]),
);
});
+2
View File
@@ -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),
+43 -1
View File
@@ -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,
+4
View File
@@ -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. */
+22
View File
@@ -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()
+270
View File
@@ -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 };
});
}
+27
View File
@@ -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');
}
+30
View File
@@ -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');
});
});
+44
View File
@@ -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),
];
}
+34
View File
@@ -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'));
});
}
+2
View File
@@ -66,6 +66,8 @@ function snapshot(): Snapshot {
routes: [],
ringGroups: [],
ivrs: [],
timeConditions: [],
holidays: [],
extensions: [ext('101', 'Alice'), ext('102', 'Bob')],
};
}
+2
View File
@@ -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',
+2
View File
@@ -61,6 +61,8 @@ function snapshot(): Snapshot {
routes: [],
ringGroups: [],
ivrs: [],
timeConditions: [],
holidays: [],
extensions: [ext('101', 'Alice'), ext('102', 'Bob')],
};
}
+160
View File
@@ -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);
});
});
+25
View File
@@ -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 }),
};
+26 -17
View File
@@ -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;
};
}
+3
View File
@@ -17,6 +17,7 @@ import { SettingsPage } from './pages/admin/SettingsPage.tsx';
import { SoundsPage } from './pages/admin/SoundsPage.tsx';
import { RingGroupsPage } from './pages/admin/RingGroupsPage.tsx';
import { 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 ?? ''} />
),
+1 -1
View File
@@ -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>
);
}
+12
View File
@@ -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
+2
View File
@@ -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`)
+1
View File
@@ -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' },
]);
});
});
+190
View File
@@ -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),
);
}
+6
View File
@@ -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.
+228
View File
@@ -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();
}