%%%-----------------------------------------------------------------------------
%%% @copyright (C) 2011-2019, 2600Hz
%%% @doc Controls and picks Callflows based rules.
%%%
%%%
Data options:
%%%
%%% - `action'
%%% - One of: `menu', `enable', `disable', `reset'.
%%%
%%% - `rules'
%%% - List of the rules.
%%%
%%% - `interdigit_timeout'
%%% - How long to wait for the next DTMF, in milliseconds. Default is 2000.
%%%
%%%
%%% @author Karl Anderson
%%% @end
%%%-----------------------------------------------------------------------------
-module(cf_temporal_route).
-behaviour(gen_cf_action).
-include("callflow.hrl").
-include("cf_temporal_route.hrl").
-export([handle/2]).
-ifdef(TEST).
-export([next_rule_date/2
,sort_wdays/1
]).
-endif.
%%------------------------------------------------------------------------------
%% @doc
%% @end
%%------------------------------------------------------------------------------
-spec handle(kz_json:object(), kapps_call:call()) -> any().
handle(Data, Call) ->
Temporal = get_temporal_route(Data, Call),
case action(Data) of
<<"menu">> ->
lager:info("temporal rules main menu"),
_ = temporal_route_menu(Temporal, rule_ids(Data), Call),
cf_exe:stop(Call);
<<"enable">> ->
lager:info("force temporal rules to enable"),
_ = enable_temporal_rules(Temporal, rule_ids(Data), Call),
cf_exe:stop(Call);
<<"disable">> ->
lager:info("force temporal rules to disable"),
_ = disable_temporal_rules(Temporal, rule_ids(Data), Call),
cf_exe:stop(Call);
<<"reset">> ->
lager:info("resume normal temporal rule operation"),
_ = reset_temporal_rules(Temporal, rule_ids(Data), Call),
cf_exe:stop(Call);
_Action ->
Rules = get_temporal_rules(Temporal, Call),
case process_rules(Temporal, Rules, Call) of
'default' ->
cf_exe:continue(Call);
ChildId ->
cf_exe:continue(ChildId, Call)
end
end.
%%------------------------------------------------------------------------------
%% @doc Test all rules in reference to the current temporal routes, and
%% returns the first valid callflow, or the default.
%% @end
%%------------------------------------------------------------------------------
-spec process_rules(temporal(), rules(), kapps_call:call()) ->
'default' | binary().
process_rules(Temporal
,[#rule{enabled='false'
,id=Id
,name=Name
}
| Rules
]
,Call
) ->
lager:info("time based rule ~p (~s) disabled", [Id, Name]),
process_rules(Temporal, Rules, Call);
process_rules(_Temporal
,[#rule{enabled='true'
,id=Id
,name=Name
}
| _Rules
]
,_Call
) ->
lager:info("time based rule ~p (~s) is forced active", [Id, Name]),
Id;
process_rules(Temporal
,[#rule{id=Id
,name=Name
,cycle = <<>>
}
| Rules
]
,Call
) ->
lager:error("time based rule ~p (~s) is invalid, skipping", [Id, Name]),
process_rules(Temporal, Rules, Call);
process_rules(#temporal{local_sec=LSec
,local_date={Y, M, D}
}=T
,[#rule{cycle=Cycle
,id=Id
,name=Name
,wtime_start=TStart
,wtime_stop=TStop
}=Rule
| Rules
]
,Call
) ->
lager:info("processing temporal rule ~s (~s)", [Id, Name]),
%% Weekly logic becomes convoluted when prev date is passed for SearchDate.
%% This creates lots of edge cases so pass today in weekly only.
SearchDate = case Cycle of
<<"weekly">> ->
{Y, M, D};
_ ->
kz_date:normalize({Y, M, D - 1})
end,
BaseDate = kz_date:normalize(
next_rule_date(Rule, SearchDate)
),
BaseTime = calendar:datetime_to_gregorian_seconds({BaseDate, {0,0,0}}),
case {BaseTime + TStart, BaseTime + TStop} of
{Start, _} when LSec < Start ->
lager:info("rule applies in the future ~w", [calendar:gregorian_seconds_to_datetime(Start)]),
process_rules(T, Rules, Call);
{_, End} when LSec > End ->
lager:info("rule was valid today but expired ~w", [calendar:gregorian_seconds_to_datetime(End)]),
process_rules(T, Rules, Call);
{_, End} ->
lager:info("within active time window until ~w", [calendar:gregorian_seconds_to_datetime(End)]),
Id
end;
process_rules(_Temporal, [], _Call) ->
lager:info("continuing with default callflow"),
'default'.
%%------------------------------------------------------------------------------
%% @doc Finds and returns a list of rule records that have do not occur in
%% the future as well as pertain to this temporal route mapping.
%% @end
%%------------------------------------------------------------------------------
-spec get_temporal_rules(temporal(), kapps_call:call()) -> rules().
get_temporal_rules(#temporal{local_sec=LSec
,routes=Routes
,timezone=TZ
}
,Call
) ->
get_temporal_rules(Routes, LSec, kapps_call:account_db(Call), TZ, []).
-spec get_temporal_rules(kz_json:path(), non_neg_integer(), kz_term:ne_binary(), kz_term:ne_binary(), rules()) -> rules().
get_temporal_rules(Routes, LSec, AccountDb, TZ, Rules) when is_binary(TZ) ->
Now = localtime:utc_to_local(calendar:universal_time(), kz_term:to_list(TZ)),
get_temporal_rules(Routes, LSec, AccountDb, TZ, Now, Rules).
-spec get_temporal_rules(routes(), non_neg_integer(), kz_term:ne_binary(), kz_term:ne_binary(), kz_time:datetime(), rules()) -> rules().
get_temporal_rules([], _, _, _, _, Rules) -> lists:reverse(Rules);
get_temporal_rules([{Route, Id}|Routes], LSec, AccountDb, TZ, Now, Rules) ->
case kz_datamgr:open_cache_doc(AccountDb, Route) of
{'error', _R} ->
lager:info("unable to find temporal rule ~s in ~s", [Route, AccountDb]),
get_temporal_rules(Routes, LSec, AccountDb, TZ, Now, Rules);
{'ok', JObj} ->
maybe_build_rule(Routes, LSec, AccountDb, TZ, Now, Rules, Id, JObj)
end.
-spec maybe_build_rule(routes(), non_neg_integer(), kz_term:ne_binary(), kz_term:ne_binary(), kz_time:datetime(), rules(), kz_term:ne_binary(), kzd_temporal_rules:doc()) -> rules().
maybe_build_rule(Routes, LSec, AccountDb, TZ, Now, Rules, Id, RulesDoc) ->
StartDate = kz_date:from_gregorian_seconds(kzd_temporal_rules:start_date(RulesDoc, LSec), TZ),
RuleName = kzd_temporal_rules:name(RulesDoc, ?RULE_DEFAULT_NAME),
case kz_date:relative_difference(Now, {StartDate, {0,0,0}}) of
'future' ->
lager:warning("rule ~p is in the future discarding", [RuleName]),
get_temporal_rules(Routes, LSec, AccountDb, TZ, Now, Rules);
_ ->
get_temporal_rules(Routes, LSec, AccountDb, TZ, Now, [build_rule(Id, RulesDoc, StartDate, RuleName) | Rules])
end.
-spec build_rule(kz_term:ne_binary(), kzd_temporal_rules:doc(), kz_time:date(), kz_term:ne_binary()) -> rule().
build_rule(Id, RulesDoc, StartDate, RuleName) ->
#rule{cycle = kzd_temporal_rules:cycle(RulesDoc, ?RULE_DEFAULT_CYCLE)
,days = days_in_rule(RulesDoc)
,enabled = kzd_temporal_rules:enabled(RulesDoc, 'undefined')
,id = Id
,interval = kzd_temporal_rules:interval(RulesDoc, ?RULE_DEFAULT_INTERVAL)
,month = kzd_temporal_rules:month(RulesDoc, ?RULE_DEFAULT_MONTH)
,name = RuleName
,ordinal = kzd_temporal_rules:ordinal(RulesDoc, ?RULE_DEFAULT_ORDINAL)
,start_date = StartDate
,wdays = sort_wdays(kzd_temporal_rules:wdays(RulesDoc, ?RULE_DEFAULT_WDAYS))
,wtime_start = kzd_temporal_rules:time_window_start(RulesDoc, ?RULE_DEFAULT_WTIME_START)
,wtime_stop = kzd_temporal_rules:time_window_stop(RulesDoc, ?RULE_DEFAULT_WTIME_STOP)
}.
-spec days_in_rule(kzd_temporal_rules:doc()) -> kz_term:integers().
days_in_rule(RulesDoc) ->
lists:foldr(fun add_day/2, [], kzd_temporal_rules:days(RulesDoc, ?RULE_DEFAULT_DAYS)).
-spec add_day(kz_term:ne_binary() | integer(), kz_term:integers()) -> kz_term:integers().
add_day(Day, Acc) -> [kz_term:to_integer(Day)|Acc].
%%------------------------------------------------------------------------------
%% @doc Loads the temporal record with data from the db.
%% @end
%%------------------------------------------------------------------------------
-spec maybe_load_rules(kz_json:object(), kapps_call:call(), routes()) -> routes().
maybe_load_rules(Data, _Call, Routes) ->
Rules = rule_ids(Data),
lager:info("loaded ~p routes from rules", [length(Rules)]),
Routes ++ [{X, X} || X <- Rules].
-spec maybe_load_branch_keys(kz_json:object(), kapps_call:call(), routes()) -> routes().
maybe_load_branch_keys(_Data, Call, Routes) ->
{'branch_keys', Rules} = cf_exe:get_branch_keys(Call),
lager:info("loaded ~p routes from branch_keys", [length(Rules)]),
Routes ++ [{X, X} || X <- Rules].
-spec maybe_load_rulesets(kz_json:object(), kapps_call:call(), routes()) -> routes().
maybe_load_rulesets(Data, Call, Routes) ->
case rule_set_id(Data) of
'undefined' ->
lager:info("no rule_set id configured"),
Routes;
RuleSetId ->
lager:info("loading rules from rule_set ~p", [RuleSetId]),
Routes ++ [{X, <<"rule_set">>} || X <- get_rule_set(RuleSetId, Call)]
end.
-spec maybe_expand_rulesets(kz_json:object(), kapps_call:call(), routes()) -> routes().
maybe_expand_rulesets(_Data, Call, Rules) ->
try_load_rulesets(Call, lists:flatten(Rules), []).
-spec try_load_rulesets(kapps_call:call(), routes(), routes()) -> routes().
try_load_rulesets(_Call, [], Acc) ->
lists:flatten(Acc);
try_load_rulesets(Call, [{_,<<"rule_set">>}=H|T], Acc) ->
try_load_rulesets(Call, T, Acc ++ [H]);
try_load_rulesets(Call, [{H,_}|T], Acc) ->
UseRoutes = case get_rule_set(H, Call) of
[] ->
[{H, H}];
SetVals ->
lager:info("loaded ~p rules from rule_set ~p", [length(SetVals), H]),
[{X, H} || X <- SetVals]
end,
try_load_rulesets(Call, T, Acc ++ UseRoutes).
-spec get_temporal_route(kz_json:object(), kapps_call:call()) -> temporal().
get_temporal_route(Data, Call) ->
lager:info("loading temporal route..."),
Routes = lists:foldl(fun(F, A) -> F(Data, Call, A) end
,[]
,[fun maybe_load_rules/3
,fun maybe_load_branch_keys/3
,fun maybe_load_rulesets/3
,fun maybe_expand_rulesets/3
]),
lager:info("routes are: ~p", [Routes]),
load_current_time(#temporal{routes = Routes
,timezone = cf_util:get_timezone(Data, Call)
,interdigit_timeout = interdigit_timeout(Data)
}).
%%------------------------------------------------------------------------------
%% @doc Loads rules set from account db.
%% @end
%%------------------------------------------------------------------------------
-spec get_rule_set(route() | kz_term:ne_binary(), kapps_call:call()) -> kz_term:ne_binaries().
get_rule_set({Id, Id}, Call) ->
get_rule_set(Id, Call);
get_rule_set(Id, Call) ->
AccountDb = kapps_call:account_db(Call),
lager:info("loading temporal rule set ~s", [Id]),
case kz_datamgr:open_cache_doc(AccountDb, Id) of
{'error', _E} ->
lager:error("failed to load ~s in ~s", [Id, AccountDb]),
[];
{'ok', TemporalRulesSet} ->
kzd_temporal_rules_sets:temporal_rules(TemporalRulesSet, [])
end.
%%------------------------------------------------------------------------------
%% @doc Present the caller with the option to enable, disable, or reset
%% the provided temporal rules.
%% @end
%%------------------------------------------------------------------------------
-spec temporal_route_menu(temporal(), rule_ids(), kapps_call:call()) -> cf_api_std_return().
temporal_route_menu(#temporal{keys=#keys{enable=Enable
,disable=Disable
,reset=Reset
}
,prompts=#prompts{main_menu=MainMenu}
,interdigit_timeout=Interdigit
}=Temporal
,Rules
,Call
) ->
NoopId = kapps_call_command:prompt(MainMenu, Call),
case kapps_call_command:collect_digits(1
,kapps_call_command:default_collect_timeout()
,Interdigit
,NoopId
,Call
)
of
{'ok', Enable} ->
enable_temporal_rules(Temporal, Rules, Call);
{'ok', Disable} ->
disable_temporal_rules(Temporal, Rules, Call);
{'ok', Reset} ->
reset_temporal_rules(Temporal, Rules, Call);
{'error', _} ->
{'ok', kz_json:new()};
{'ok', _} ->
temporal_route_menu(Temporal, Rules, Call)
end.
%%------------------------------------------------------------------------------
%% @doc Retrieve and update the enabled key on the temporal rule document.
%% Also plays messages to the caller based on the results of that
%% operation.
%% @end
%%------------------------------------------------------------------------------
-spec disable_temporal_rules(temporal(), rule_ids(), kapps_call:call()) -> cf_api_std_return().
disable_temporal_rules(#temporal{prompts=#prompts{marked_disabled=Disabled}}, [], Call) ->
kapps_call_command:b_prompt(Disabled, Call);
disable_temporal_rules(Temporal, [RuleId|T]=Rules, Call) ->
try
AccountDb = kapps_call:account_db(Call),
{'ok', JObj} = kz_datamgr:open_doc(AccountDb, RuleId),
case kz_datamgr:save_doc(AccountDb, kzd_temporal_rules:set_enabled(JObj, 'false')) of
{'ok', _} ->
lager:info("set temporal rule ~s to disabled", [RuleId]),
disable_temporal_rules(Temporal, T, Call);
{'error', 'conflict'} ->
lager:info("conflict during disable of temporal rule ~s, trying again", [RuleId]),
disable_temporal_rules(Temporal, Rules, Call);
{'error', R1} ->
lager:info("unable to update temporal rule ~s, ~p", [RuleId, R1]),
disable_temporal_rules(Temporal, T, Call)
end
catch
_:R2 ->
lager:info("unable to update temporal rules ~p", [R2]),
disable_temporal_rules(Temporal, T, Call)
end.
%%------------------------------------------------------------------------------
%% @doc Retrieve and update the enabled key on the temporal rule document.
%% Also plays messages to the caller based on the results of that
%% operation.
%% @end
%%------------------------------------------------------------------------------
-spec reset_temporal_rules(temporal(), rule_ids(), kapps_call:call()) -> cf_api_std_return().
reset_temporal_rules(#temporal{prompts=#prompts{marker_reset=Reset}}, [], Call) ->
kapps_call_command:b_prompt(Reset, Call);
reset_temporal_rules(Temporal, [RuleId|T]=Rules, Call) ->
try
AccountDb = kapps_call:account_db(Call),
{'ok', JObj} = kz_datamgr:open_doc(AccountDb, RuleId),
case kz_datamgr:save_doc(AccountDb, kzd_temporal_rules:delete_enabled(JObj)) of
{'ok', _} ->
lager:info("reset temporal rule ~s", [RuleId]),
reset_temporal_rules(Temporal, T, Call);
{'error', 'conflict'} ->
lager:info("conflict during reset of temporal rule ~s, trying again", [RuleId]),
reset_temporal_rules(Temporal, Rules, Call);
{'error', R1} ->
lager:info("unable to reset temporal rule ~s, ~p", [RuleId, R1]),
reset_temporal_rules(Temporal, T, Call)
end
catch
_:R2 ->
lager:info("unable to reset temporal rule ~s ~p", [RuleId, R2]),
reset_temporal_rules(Temporal, T, Call)
end.
%%------------------------------------------------------------------------------
%% @doc Retrieve and update the enabled key on the temporal rule document.
%% Also plays messages to the caller based on the results of that
%% operation.
%% @end
%%------------------------------------------------------------------------------
-spec enable_temporal_rules(temporal(), rule_ids(), kapps_call:call()) -> cf_api_std_return().
enable_temporal_rules(#temporal{prompts=#prompts{marked_enabled=Enabled}}, [], Call) ->
kapps_call_command:b_prompt(Enabled, Call);
enable_temporal_rules(Temporal, [RuleId|T]=Rules, Call) ->
try
AccountDb = kapps_call:account_db(Call),
{'ok', RuleDoc} = kz_datamgr:open_doc(AccountDb, RuleId),
case kz_datamgr:save_doc(AccountDb, kzd_temporal_rules:set_enabled(RuleDoc, 'true')) of
{'ok', _} ->
lager:info("set temporal rule ~s to enabled active", [RuleId]),
enable_temporal_rules(Temporal, T, Call);
{'error', 'conflict'} ->
lager:info("conflict during enable of temporal rule ~s, trying again", [RuleId]),
enable_temporal_rules(Temporal, Rules, Call);
{'error', R1} ->
lager:info("unable to enable temporal rule ~s, ~p", [RuleId, R1]),
enable_temporal_rules(Temporal, T, Call)
end
catch
_:R2 ->
lager:info("unable to enable temporal rule ~s ~p", [RuleId, R2]),
enable_temporal_rules(Temporal, T, Call)
end.
%%------------------------------------------------------------------------------
%% @doc determines the appropriate Gregorian seconds to be used as the
%% current date/time for this temporal route selection
%% @end
%%------------------------------------------------------------------------------
-spec load_current_time(temporal()) -> temporal().
load_current_time(#temporal{timezone=Timezone}=Temporal)->
{LocalDate, LocalTime} = localtime:utc_to_local(calendar:universal_time()
,kz_term:to_list(Timezone)
),
lager:info("local time for ~s is {~w,~w}", [Timezone, LocalDate, LocalTime]),
Temporal#temporal{local_sec=calendar:datetime_to_gregorian_seconds({LocalDate, LocalTime})
,local_date=LocalDate
,local_time=LocalTime
}.
%%------------------------------------------------------------------------------
%% @doc The big daddy
%% Calculates the date of the next event given the type, interval,
%% rule, start date, and current date.
%%
%% GOTCHA!
%% Weird predictions? Bet your weekdays or days are not in order....
%% - monday, tuesday, wensday, thursday, friday, saturday, sunday
%% - 1,2,3..31
%% @end
%%------------------------------------------------------------------------------
-spec next_rule_date(rule(), kz_time:date()) -> kz_time:date().
next_rule_date(#rule{cycle = <<"date">>
,start_date=Date0
}
,_Date
) ->
Date0;
next_rule_date(#rule{cycle = <<"daily">>
,interval=I0
,start_date={Y0, M0, D0}
}
,{Y1, M1, D1}
) ->
%% Calculate the distance in days as a function of
%% the interval and fix
DS0 = calendar:date_to_gregorian_days({Y0, M0, D0}),
DS1 = calendar:date_to_gregorian_days({Y1, M1, D1}),
Offset = trunc( ( DS1 - DS0 ) / I0 ) * I0,
kz_date:normalize({Y0, M0, D0 + Offset + I0});
next_rule_date(#rule{cycle = <<"weekly">>
,interval=Interval
,wdays=Weekdays
,start_date={Y0, M0, D0}=StartDate
}=Rule
,{Y1, M1, D1}=Today
) ->
DOW0 = calendar:day_of_the_week({Y1, M1, D1}),
Distance = iso_week_difference({Y0, M0, D0}, {Y1, M1, D1}),
Offset = trunc( Distance / Interval ) * Interval,
Weekday = calendar:day_of_the_week(StartDate),
%%TODO: remove these log lines when we are happy that this just works
lager:debug("today is: ~p dow: ~p, startdate is: ~p, start dow is ~b, interval is: ~b, distance is: ~b, offset is: ~b, rule days found: ~p"
,[Today, DOW0, StartDate, Weekday, Interval, Distance, Offset, find_active_days(Weekdays, DOW0)]
),
case find_active_days(Weekdays, DOW0) of
%% When the start date is in the future but within the week,
%% skip over the invalid rule dates by recursively calling
%% self with Today as StartDate
[_Day|_] when Today < StartDate
andalso Distance =:= Offset ->
lager:debug("rule starts in the future jumping to search from ~p", [StartDate]),
next_rule_date(Rule, StartDate);
%% When today is the first rule day and also the start date return the start date
[Day|_] when Today =:= StartDate
andalso Day =:= DOW0
andalso Distance =:= Offset ->
lager:debug("rule starts today ~b", [Day]),
StartDate;
%% During an 'active' week that fails the previous guards, move to the next day this week
[Day|_] when Distance =:= Offset ->
lager:debug("next day in rule is ~w and day is ~w", [Day, DOW0]),
kz_date:normalize({Y1, M1, D1 + Day - DOW0});
%% Empty list:
%% Non Empty List that failed the guard:
%% During an 'inactive' week
_Val ->
lager:debug("no rule found for this week"),
{WY0, W0} = calendar:iso_week_number({Y0, M0, D0}),
{Y2, M2, D2} = kz_date:from_iso_week({WY0, W0 + Offset + Interval}),
kz_date:normalize({Y2, M2, ( D2 - 1 ) + kz_date:wday_to_dow( hd( Weekdays ) )})
end;
next_rule_date(#rule{cycle = <<"monthly">>
,interval=I0
,days=[_|_]=Days
,start_date={Y0, M0, _}
}
,{Y1, M1, D1}
) ->
Distance = ( Y1 - Y0 ) * 12 - M0 + M1,
Offset = trunc( Distance / I0 ) * I0,
case [D || D <- Days, D > D1] of
%% The day hasn't happened on an 'active' month
[Day|_] when Distance =:= Offset ->
M01 = M0 + Offset,
kz_date:normalize({Y0 + (M01 div 12), M01 rem 12, Day});
%% Empty List:
%% All of the days in the list have already happened
%% Non Empty List that failed the guard:
%% The day hasn't happened on an 'inactive' month
_ ->
M01 = M0 + Offset + I0,
kz_date:normalize({Y0 + (M01 div 12), M01 rem 12, hd(Days)})
end;
next_rule_date(#rule{cycle = <<"monthly">>
,interval=I0
,ordinal = <<"every">>
,wdays=[Weekday]
,start_date={Y0, M0, _}
}
,{Y1, M1, D1}
) ->
Distance = ( Y1 - Y0 ) * 12 - M0 + M1,
Offset = trunc( Distance / I0 ) * I0,
case Distance =:= Offset
andalso kz_date:find_next_weekday({Y1, M1, D1}, Weekday)
of
%% If the next occurrence of the weekday is during an 'active' month
%% and does not span the current month/year then it is correct
{Y1, M1, _}=Date ->
Date;
%% In the special case were the next weekday does span the current
%% month/year but it should be every month (I0 == 1) then the
%% date is also correct
{_,_,_}=Date when I0 =:= 1 ->
Date;
%% During an 'inactive' month, or when it inappropriately spans
%% a month/year boundary calculate the next iteration
_ ->
find_ordinal_weekday(Y0, M0 + Offset + I0, Weekday, <<"first">>)
end;
next_rule_date(#rule{cycle = <<"monthly">>
,interval=I0
,ordinal = <<"last">>
,wdays=[Weekday]
,start_date={Y0, M0, _}
}
,{Y1, M1, D1}
) ->
Distance = ( Y1 - Y0 ) * 12 - M0 + M1,
Offset = trunc( Distance / I0 ) * I0,
case Distance =:= Offset
andalso find_last_weekday({Y1, M1, 1}, Weekday)
of
%% If today is before the occurrence day on an 'active' month since
%% the 'last' only happens once per month if we haven't passed it
%% then it must be this month
{_, _, D2}=Date when D1 < D2 ->
Date;
%% In an 'inactive' month or when we have already passed
%% the last occurrence of the DOW
_ ->
find_last_weekday({Y0, M0 + Offset + I0, 1}, Weekday)
end;
%% WARNING: There is a known bug when requesting the fifth occurrence
%% of a weekday when I0 > 1 and the current month only has four instances
%% of the given weekday, the calculation is incorrect. I was told not
%% to worry about that now...
next_rule_date(#rule{cycle = <<"monthly">>
,interval=I0
,ordinal=Ordinal
,wdays=[Weekday]
,start_date={Y0, M0, _}
}
,{Y1, M1, D1}
) ->
Distance = ( Y1 - Y0 ) * 12 - M0 + M1,
Offset = trunc( Distance / I0 ) * I0,
case Distance =:= Offset
andalso {find_ordinal_weekday(Y1, M1, Weekday, Ordinal), I0}
of
%% If today is before the occurrence day on an 'active' month and
%% the occurrence does not cross month/year boundaries then the
%% calculated date is accurate
{{_, M1, D2}=Date, _} when D1 < D2, I0 > 1 ->
Date;
%% If today is before the occurrence day on an 'active' month and
%% the interval =:= 1 then it happens every month so it doesn't
%% matter if it crosses month/year boundaries
{{_, M2, D2}=Date, 1} when D1 < D2; M1 < M2 ->
Date;
%% false:
%% In an 'inactive' month
%% {kz_time:date(), integer()}:
%% We have already passed the last occurrence of the DOW
_ ->
find_ordinal_weekday(Y0, M0 + Offset + I0, Weekday, Ordinal)
end;
%% WARNING: This function does not ensure the provided day actually
%% exists in the month provided. For temporal routes that isn't
%% an issue because we will 'pass' the invalid date and compute
%% the next
next_rule_date(#rule{cycle = <<"yearly">>
,interval=I0
,month=Month
,days=[_|_]=Days
,start_date={Y0, _, _}
}
,{Y1, M1, D1}
) ->
Distance = Y1 - Y0,
Offset = trunc( Distance / I0 ) * I0,
case Distance =:= Offset of
%% If this is not an 'active' year it will be the first specified
%% day (of days) next interval year(s)
'false' ->
{Y0 + Offset + I0, Month, hd(Days)};
%% If this an 'active' year but the month has not occurred yet
%% it will be on the first day (of days) that month
'true' when M1 < Month ->
{Y1, Month, hd(Days)};
%% If this an 'active' year but the month has not occurred yet
%% it will be on the first day (of days) next interval year(s)
'true' when M1 > Month ->
{Y0 + Offset + I0, Month, hd(Days)};
'true' ->
case lists:dropwhile(fun(D) -> D1 >= D end, Days) of
%% if this is the month but the all the days have passed
%% it will be on the first day (of days) next interval year(s)
[] -> {Y0 + Offset + I0, Month, hd(Days)};
%% if not all the days have passed is is the next day after the
%% ones that have passed
[Day|_] ->
{Y1, Month, Day}
end
end;
next_rule_date(#rule{cycle = <<"yearly">>
,interval=I0
,ordinal = <<"every">>
,month=Month
,wdays=[Weekday]
,start_date={Y0, _, _}
}
,{Y1, M1, D1}
) ->
Distance = Y1 - Y0,
Offset = trunc( Distance / I0 ) * I0,
case Distance =:= Offset
andalso kz_date:find_next_weekday({Y1, Month, D1}, Weekday)
of
%% During an 'active' year before the target month the calculated
%% occurrence is accurate
{Y1, Month, _}=Date when M1 < Month ->
Date;
%% During an 'active' year on the target month before the
%% calculated occurrence day it is accurate
{Y1, Month, D2}=Date when M1 =:= Month, D1 < D2 ->
Date;
%% During an 'inactive' year, or after the target month
%% calculate the next iteration
_ ->
find_ordinal_weekday(Y0 + Offset + I0, Month, Weekday, <<"first">>)
end;
next_rule_date(#rule{cycle = <<"yearly">>
,interval=I0
,ordinal = <<"last">>
,month=Month
,wdays=[Weekday]
,start_date={Y0, _, _}
}
,{Y1, M1, D1}
) ->
Distance = Y1 - Y0,
Offset = trunc( Distance / I0 ) * I0,
case Distance =:= Offset
andalso find_last_weekday({Y1, Month, 1}, Weekday)
of
%% During an 'active' year before the target month the calculated
%% occurrence is accurate
{Y1, _, _}=Date when M1 < Month ->
Date;
%% During an 'active' year on the target month before the
%% calculated occurrence day it is accurate
{Y1, _, D2}=Date when M1 =:= Month, D1 < D2 ->
Date;
%% During an 'inactive' year, or after the target month
%% calculate the next iteration
_ ->
find_last_weekday({Y0 + Offset + I0, Month, 1}, Weekday)
end;
next_rule_date(#rule{cycle = <<"yearly">>
,interval=I0
,ordinal=Ordinal
,month=Month
,wdays=[Weekday]
,start_date={Y0, _, _}
}
,{Y1, M1, D1}
) ->
Distance = Y1 - Y0,
Offset = trunc( Distance / I0 ) * I0,
case Distance =:= Offset
andalso find_ordinal_weekday(Y1, Month, Weekday, Ordinal)
of
%% During an 'active' year before the target month the calculated
%% occurrence is accurate
{Y1, Month, _}=Date when M1 < Month ->
Date;
%% During an 'active' year on the target month before the
%% calculated occurrence day it is accurate
{Y1, Month, D2}=Date when M1 =:= Month, D1 < D2 ->
Date;
%% During an 'inactive' year or after the calculated
%% occurrence, determine the next iteration
_ ->
find_next_yearly_ordinal_weekday(Y0 + Offset + I0, Month, Weekday, Ordinal, I0)
end.
-spec find_next_yearly_ordinal_weekday(kz_time:year(), kz_time:month(), wday(), kz_time:ordinal(), interval()) -> kz_time:date().
find_next_yearly_ordinal_weekday(Y0, M0, Weekday, Ordinal, Interval) ->
case find_ordinal_weekday(Y0, M0, Weekday, Ordinal) of
{_Y1, M0, _D1}=Date ->
%% found a date in the same month
Date;
{_Y1, _M1, _D1} ->
%% might be a "fifth" day in the next month,
%% let's try again
find_next_yearly_ordinal_weekday(Y0 + Interval, M0, Weekday, Ordinal, Interval)
end.
%%------------------------------------------------------------------------------
%% @doc Safety wrapper on date_of_dow used to loop over failing attempts
%% until the date can be calculated. The date can be provided as an
%% improper date.
%%
%% It is possible for this function to cross month/year boundaries.
%% @end
%%------------------------------------------------------------------------------
-spec find_ordinal_weekday(kz_time:year(), improper_month(), wday(), kz_time:ordinal()) -> kz_time:date().
find_ordinal_weekday(Y1, M1, Weekday, Ordinal) when M1 =:= 13 ->
find_ordinal_weekday(Y1 + 1, 1, Weekday, Ordinal);
find_ordinal_weekday(Y1, M1, Weekday, Ordinal) when M1 > 12 ->
find_ordinal_weekday(Y1 + 1, M1 - 12, Weekday, Ordinal);
find_ordinal_weekday(Y1, M1, Weekday, Ordinal) ->
try date_of_dow(Y1, M1, Weekday, Ordinal)
catch
_:_ ->
find_ordinal_weekday(Y1, M1 + 1, Weekday, Ordinal)
end.
%%------------------------------------------------------------------------------
%% @doc Calculates the date of the last occurrence of a weekday within a
%% given month/year. The date can be provided as an improper date.
%%
%% Assumption/Principle:
%% A DOW can never occur more than four times in a month.
%% @end
%%------------------------------------------------------------------------------
%% First attempt to calculate the date of the fourth DOW
%% occurrence. Since the function corrects an invalid
%% date by crossing month/year boundaries, cause a badmatch
%% if this happens. Therefore, during the exception the last
%% occurrence MUST be in the third week.
%% @end
%%------------------------------------------------------------------------------
-spec find_last_weekday(improper_date(), wday()) -> kz_time:date().
find_last_weekday({Y, M, D}, Weekday) when M =:= 13 ->
find_last_weekday({Y + 1, 1, D}, Weekday);
find_last_weekday({Y, M, D}, Weekday) when M > 12 ->
find_last_weekday({Y + 1, M - 12, D}, Weekday);
find_last_weekday({Y, M, _}, Weekday) ->
try
{Y, M, _} = date_of_dow(Y, M, Weekday, <<"fifth">>)
catch
_:_ ->
date_of_dow(Y, M, Weekday, <<"fourth">>)
end.
%%------------------------------------------------------------------------------
%% @doc Unsafe calculation of the date for a specific day of the week, this
%% function will explode on occasion.
%% @end
%%------------------------------------------------------------------------------
-spec date_of_dow(kz_time:year(), improper_month(), wday(), strict_ordinal()) -> kz_time:date().
date_of_dow(Year, 1, Weekday, Ordinal) ->
date_of_dow(Year - 1, 13, Weekday, Ordinal);
date_of_dow(Year, Month, Weekday, Ordinal) ->
RefDate = {Year, Month - 1, calendar:last_day_of_the_month(Year, Month - 1)},
RefDays = calendar:date_to_gregorian_days(RefDate),
DOW = kz_date:wday_to_dow(Weekday),
Occurance = kz_date:ordinal_to_position(Ordinal),
Days = case calendar:day_of_the_week(RefDate) of
DOW ->
RefDays + 7 + (7 * Occurance );
RefDOW when DOW < RefDOW ->
RefDays + DOW + (7 - RefDOW) + (7 * Occurance);
RefDOW ->
RefDays + abs(DOW - RefDOW) + (7 * Occurance)
end,
{Y, M, D} = calendar:gregorian_days_to_date(Days),
kz_date:normalize({Y, M, D}).
%%------------------------------------------------------------------------------
%% @doc Calculates the distance, in total ISO weeks, between two dates
%% I rather dislike this approach, but it is the best of MANY evils that I came up with...
%% The idea here is to find the difference (in days) between the ISO 8601 Mondays
%% of the start and end dates. This takes care of all the corner cases for us such as:
%% - Start date in ISO week of previous year
%% - End date in ISO week of previous year
%% - Spanning years
%% All while remaining ISO 8601 compliant.
%% @end
%%------------------------------------------------------------------------------
-spec iso_week_difference(kz_time:date(), kz_time:date()) -> non_neg_integer().
iso_week_difference({Y0, M0, D0}, {Y1, M1, D1}) ->
DS0 = calendar:date_to_gregorian_days(kz_date:from_iso_week(calendar:iso_week_number({Y0, M0, D0}))),
DS1 = calendar:date_to_gregorian_days(kz_date:from_iso_week(calendar:iso_week_number({Y1, M1, D1}))),
trunc( abs( DS0 - DS1 ) / 7 ).
-spec find_active_days(kz_term:ne_binaries(), kz_time:day()) -> [kz_time:daynum()].
find_active_days(Weekdays, DOW0) ->
[DOW1
|| DOW1 <- [kz_date:wday_to_dow(D) || D <- Weekdays],
DOW1 >= DOW0
].
-spec sort_wdays([wday()]) -> [wday()].
sort_wdays([]) -> [kz_date:dow_to_wday(D) || D <- lists:seq(1, 7)];
sort_wdays(WDays0) ->
{_, WDays1} = lists:unzip(
lists:keysort(1, [{kz_date:wday_to_dow(Day), Day} || Day <- WDays0])
),
WDays1.
-spec interdigit_timeout(kz_json:object()) -> integer().
interdigit_timeout(Data) ->
kz_json:get_integer_value(<<"interdigit_timeout">>
,Data
,kapps_call_command:default_interdigit_timeout()
).
-type rule_ids() :: kz_term:ne_binaries().
-spec rule_ids(kz_json:object()) -> rule_ids().
rule_ids(Data) ->
kz_json:get_list_value(<<"rules">>, Data, []).
-spec action(kz_json:object()) -> kz_term:api_ne_binary().
action(Data) ->
kz_json:get_ne_binary_value(<<"action">>, Data).
-spec rule_set_id(kz_json:object()) -> kz_term:api_ne_binary().
rule_set_id(Data) ->
kz_json:get_ne_binary_value(<<"rule_set">>, Data).