diff --git a/lib/teiserver/coordinator/coordinator_server.ex b/lib/teiserver/coordinator/coordinator_server.ex
index 43e91a00eb..6a44a40689 100644
--- a/lib/teiserver/coordinator/coordinator_server.ex
+++ b/lib/teiserver/coordinator/coordinator_server.ex
@@ -16,6 +16,7 @@ defmodule Teiserver.Coordinator.CoordinatorServer do
alias Teiserver.Coordinator.Parser
alias Teiserver.Lobby
alias Teiserver.Moderation
+ alias Teiserver.Moderation.RefreshUserRestrictionsTask
alias Teiserver.Room
alias Teiserver.Telemetry
@@ -158,6 +159,7 @@ defmodule Teiserver.Coordinator.CoordinatorServer do
case converted_message do
^warning_response ->
Client.clear_awaiting_warn_ack(userid)
+ activate_warning_actions(userid)
CacheUser.send_direct_message(state.userid, userid, "Thank you")
_other_message ->
@@ -226,6 +228,28 @@ defmodule Teiserver.Coordinator.CoordinatorServer do
)
end
+ pending_actions =
+ Moderation.list_actions(
+ search: [
+ target_id: userid,
+ expiry: "Pending only"
+ ]
+ )
+
+ {login_activated, _warning_pending} =
+ Enum.split_with(pending_actions, fn action ->
+ not Enum.member?(action.restrictions, "Warning reminder")
+ end)
+
+ db_user =
+ if Enum.empty?(login_activated) do
+ db_user
+ else
+ activate_actions(login_activated)
+ RefreshUserRestrictionsTask.refresh_user(userid)
+ Account.get_user(userid)
+ end
+
relevant_restrictions =
db_user.restrictions
|> Enum.filter(fn r -> not Enum.member?(["Bridging"], r) end)
@@ -319,6 +343,34 @@ defmodule Teiserver.Coordinator.CoordinatorServer do
{:noreply, state}
end
+ defp activate_warning_actions(userid) do
+ pending_warnings =
+ Moderation.list_actions(
+ search: [
+ target_id: userid,
+ expiry: "Pending only",
+ in_restrictions: "Warning reminder"
+ ]
+ )
+
+ activate_actions(pending_warnings)
+
+ if not Enum.empty?(pending_warnings) do
+ RefreshUserRestrictionsTask.refresh_user(userid)
+ end
+ end
+
+ defp activate_actions(actions) do
+ now = DateTime.utc_now()
+
+ Enum.each(actions, fn action ->
+ if action.duration do
+ expires = DateTime.add(now, action.duration, :second)
+ Moderation.update_action(action, %{"expires" => expires})
+ end
+ end)
+ end
+
def make_and_cache_coordinator_account do
account = get_coordinator_account()
Teiserver.cache_put(:application_metadata_cache, "teiserver_coordinator_userid", account.id)
diff --git a/lib/teiserver/helpers/date_helper.ex b/lib/teiserver/helpers/date_helper.ex
index ab9a9002d5..e937abb52c 100644
--- a/lib/teiserver/helpers/date_helper.ex
+++ b/lib/teiserver/helpers/date_helper.ex
@@ -99,6 +99,9 @@ defmodule Teiserver.Helper.DateHelper do
:ymd_hms ->
Calendar.strftime(the_time, "%Y-%m-%d %I:%M:%S")
+ :ymd_hms24 ->
+ Calendar.strftime(the_time, "%Y-%m-%d %H:%M:%S")
+
:hms ->
Calendar.strftime(the_time, "%I:%M:%S")
@@ -403,7 +406,11 @@ defmodule Teiserver.Helper.DateHelper do
@human_input_regex ~r/([1-9][0-9]*?)\s?(s|seconds?|h|hours?|d|days?|m|months?|y|years?)/
@spec human_input_to_datetime(String.t(), DateTime.t() | nil) :: {:ok, DateTime.t()} | nil
- def human_input_to_datetime(human_input, now \\ nil) do
+ def human_input_to_datetime(human_input, now \\ nil)
+ def human_input_to_datetime(nil, _now), do: nil
+ def human_input_to_datetime("", _now), do: nil
+
+ def human_input_to_datetime(human_input, now) do
now = now || DateTime.utc_now()
human_input = String.downcase(human_input)
@@ -427,4 +434,32 @@ defmodule Teiserver.Helper.DateHelper do
nil
end
end
+
+ @doc """
+ Converts human input like "7d" directly to seconds without going through DateTime.
+ """
+ @spec human_input_to_seconds(String.t() | nil) :: integer() | nil
+ def human_input_to_seconds(nil), do: nil
+ def human_input_to_seconds(""), do: nil
+
+ def human_input_to_seconds(human_input) do
+ human_input = String.downcase(human_input)
+
+ case Regex.run(@human_input_regex, human_input) do
+ [_full, count, unit] ->
+ count = String.to_integer(count)
+ unit = String.first(unit)
+
+ case unit do
+ "s" -> count
+ "h" -> count * 3_600
+ "d" -> count * 86_400
+ "m" -> count * 30 * 86_400
+ "y" -> count * 365 * 86_400
+ end
+
+ _any_other ->
+ nil
+ end
+ end
end
diff --git a/lib/teiserver/moderation/libs/action_lib.ex b/lib/teiserver/moderation/libs/action_lib.ex
index 47a3302b93..56c20e80a3 100644
--- a/lib/teiserver/moderation/libs/action_lib.ex
+++ b/lib/teiserver/moderation/libs/action_lib.ex
@@ -97,6 +97,11 @@ defmodule Teiserver.Moderation.ActionLib do
def _search(query, :expiry, "All"), do: query
+ def _search(query, :expiry, "Pending only") do
+ from actions in query,
+ where: is_nil(actions.expires)
+ end
+
def _search(query, :expiry, "Completed only") do
from actions in query,
where: actions.expires < ^DateTime.utc_now()
diff --git a/lib/teiserver/moderation/libs/test_lib.ex b/lib/teiserver/moderation/libs/test_lib.ex
index 2fe38b6f47..7faabc5d91 100644
--- a/lib/teiserver/moderation/libs/test_lib.ex
+++ b/lib/teiserver/moderation/libs/test_lib.ex
@@ -30,7 +30,7 @@ defmodule Teiserver.Moderation.ModerationTestLib do
reason: "Reason",
restrictions: ["Login"],
score_modifier: 1000,
- expires: DateTime.shift(DateTime.utc_now(), day: 5)
+ duration: 5 * 86_400
}
|> Map.merge(attrs)
|> Moderation.create_action()
diff --git a/lib/teiserver/moderation/schemas/action.ex b/lib/teiserver/moderation/schemas/action.ex
index 12c1adca0d..2ce4f2d3fe 100644
--- a/lib/teiserver/moderation/schemas/action.ex
+++ b/lib/teiserver/moderation/schemas/action.ex
@@ -2,7 +2,6 @@ defmodule Teiserver.Moderation.Action do
@moduledoc false
alias Ecto.Changeset
- alias Teiserver.Helper.DateHelper
use TeiserverWeb, :schema
@@ -14,6 +13,7 @@ defmodule Teiserver.Moderation.Action do
field :restrictions, {:array, :string}
field :score_modifier, :integer
field :expires, :naive_datetime
+ field :duration, :integer
field :hidden, :boolean, default: false
@@ -31,23 +31,22 @@ defmodule Teiserver.Moderation.Action do
struct
|> cast(
params,
- ~w(target_id reason restrictions score_modifier expires notes hidden discord_message_id appeal_status)a
+ ~w(target_id reason restrictions score_modifier expires duration notes hidden discord_message_id appeal_status)a
)
- |> validate_required(~w(target_id reason restrictions expires score_modifier)a)
+ |> validate_required(~w(target_id reason restrictions score_modifier)a)
|> adjust_restrictions()
|> validate_length(:restrictions, min: 1)
end
defp adjust_restrictions(%Ecto.Changeset{} = struct) do
- years = DateTime.shift(DateTime.utc_now(), year: 10)
- expires = Changeset.get_field(struct, :expires, [])
- inbound_restrictions = Changeset.get_field(struct, :restrictions, [])
+ duration = Changeset.get_field(struct, :duration)
+ inbound_restrictions = Changeset.get_field(struct, :restrictions) || []
new_restrictions =
- if DateHelper.greater_than(expires, years) and Enum.member?(inbound_restrictions, "Login") do
+ if is_nil(duration) and Enum.member?(inbound_restrictions, "Login") do
["Permanently banned" | inbound_restrictions] |> Enum.uniq()
else
- (inbound_restrictions || []) |> List.delete("Permanently banned")
+ List.delete(inbound_restrictions, "Permanently banned")
end
Changeset.put_change(struct, :restrictions, new_restrictions)
diff --git a/lib/teiserver/moderation/tasks/refresh_user_restrictions_task.ex b/lib/teiserver/moderation/tasks/refresh_user_restrictions_task.ex
index c3b0ada710..08fa464a35 100644
--- a/lib/teiserver/moderation/tasks/refresh_user_restrictions_task.ex
+++ b/lib/teiserver/moderation/tasks/refresh_user_restrictions_task.ex
@@ -66,17 +66,21 @@ defmodule Teiserver.Moderation.RefreshUserRestrictionsTask do
|> List.flatten()
|> Enum.uniq()
- new_restricted_until =
+ expires_dates =
actions
|> Enum.map(fn a -> a.expires end)
- |> List.flatten()
- |> Enum.reduce(nil, fn
- dt1, nil ->
- dt1
+ |> Enum.reject(&is_nil/1)
- dt1, dt2 ->
- if DateHelper.compare(dt1, dt2) == :lt, do: dt1, else: dt2
- end)
+ new_restricted_until =
+ case expires_dates do
+ [] ->
+ nil
+
+ dates ->
+ Enum.reduce(dates, fn dt1, dt2 ->
+ if DateHelper.compare(dt1, dt2) == :lt, do: dt1, else: dt2
+ end)
+ end
expires_as_string = new_restricted_until |> Jason.encode!() |> Jason.decode!()
diff --git a/lib/teiserver_web/controllers/moderation/action_controller.ex b/lib/teiserver_web/controllers/moderation/action_controller.ex
index 63f09fe65b..41d168bc73 100644
--- a/lib/teiserver_web/controllers/moderation/action_controller.ex
+++ b/lib/teiserver_web/controllers/moderation/action_controller.ex
@@ -246,16 +246,7 @@ defmodule TeiserverWeb.Moderation.ActionController do
def create(conn, %{"action" => action_params}) do
user = Account.get_user(action_params["target_id"])
- expires_string = action_params["expires"] || ""
-
- expires =
- case DateHelper.human_input_to_datetime(expires_string) do
- {:ok, expires_datetime} ->
- expires_datetime
-
- nil ->
- action_params["expires"]
- end
+ duration_seconds = DateHelper.human_input_to_seconds(action_params["duration"])
restrictions =
action_params["restrictions"]
@@ -265,7 +256,8 @@ defmodule TeiserverWeb.Moderation.ActionController do
action_params =
Map.merge(action_params, %{
"restrictions" => restrictions,
- "expires" => expires
+ "duration" => duration_seconds,
+ "expires" => nil
})
report_ids =
@@ -350,14 +342,25 @@ defmodule TeiserverWeb.Moderation.ActionController do
def update(conn, %{"id" => id, "action" => action_params}) do
action = Moderation.get_action!(id)
+ duration_seconds = DateHelper.human_input_to_seconds(action_params["duration"])
+
restrictions =
action_params["restrictions"]
|> Map.values()
|> Enum.reject(fn v -> v == "false" end)
+ expires =
+ if duration_seconds != nil and action.expires != nil do
+ NaiveDateTime.add(NaiveDateTime.utc_now(), duration_seconds, :second)
+ else
+ nil
+ end
+
action_params =
Map.merge(action_params, %{
- "restrictions" => restrictions
+ "restrictions" => restrictions,
+ "duration" => duration_seconds,
+ "expires" => expires
})
case Moderation.update_action(action, action_params) do
diff --git a/lib/teiserver_web/controllers/moderation/ban_controller.ex b/lib/teiserver_web/controllers/moderation/ban_controller.ex
index 438194f7d9..ff4e213368 100644
--- a/lib/teiserver_web/controllers/moderation/ban_controller.ex
+++ b/lib/teiserver_web/controllers/moderation/ban_controller.ex
@@ -203,7 +203,7 @@ defmodule TeiserverWeb.Moderation.BanController do
reason: ban.reason,
restrictions: ["Login"],
score_modifier: 0,
- expires: DateTime.shift(DateTime.utc_now(), year: 1000)
+ duration: nil
})
ActionLib.maybe_create_discord_post(action)
diff --git a/lib/teiserver_web/templates/moderation/action/form.html.heex b/lib/teiserver_web/templates/moderation/action/form.html.heex
index 859db81163..c1ffe1e83c 100644
--- a/lib/teiserver_web/templates/moderation/action/form.html.heex
+++ b/lib/teiserver_web/templates/moderation/action/form.html.heex
@@ -26,9 +26,23 @@
- {label(f, :expires, class: "control-label")}
- {text_input(f, :expires, class: "form-control")}
- {error_tag(f, :expires)}
+ {label(f, :duration, class: "control-label")}
+ {text_input(f, :duration,
+ class: "form-control",
+ placeholder: "e.g. 7d, 24h, 1m, 1y",
+ value: seconds_to_duration_input(Ecto.Changeset.get_field(@changeset, :duration))
+ )}
+
+ Timer starts when the user logs in and acknowledges the action.
+ Leave empty for a permanent ban.
+ Format: s
+ seconds, h
+ hours, d
+ days, m
+ months, y
+ years.
+
+ {error_tag(f, :duration)}
diff --git a/lib/teiserver_web/templates/moderation/action/index.html.heex b/lib/teiserver_web/templates/moderation/action/index.html.heex
index 6139dde9a9..dd7c2d7bd4 100644
--- a/lib/teiserver_web/templates/moderation/action/index.html.heex
+++ b/lib/teiserver_web/templates/moderation/action/index.html.heex
@@ -53,20 +53,8 @@
{action.reason |> String.slice(0..100)} |
{action.restrictions |> Enum.join(", ")} |
- {action.inserted_at |> date_to_str(format: :hms_or_ymd)} |
- <%= if action.expires do %>
- <%= if NaiveDateTime.compare(NaiveDateTime.utc_now(), action.expires) == :gt do %>
- Expired |
- <% else %>
- {action.expires |> date_to_str(format: :hms_or_ymd)} |
- <% end %>
-
- {duration_to_str(action.inserted_at, action.expires)} |
- <% else %>
-
- Permanent
- |
- <% end %>
+ {action.inserted_at |> date_to_str(format: :ymd_hms24)} |
+ <.action_status action={action} />
diff --git a/lib/teiserver_web/templates/moderation/action/new_with_user.html.heex b/lib/teiserver_web/templates/moderation/action/new_with_user.html.heex
index b4cbdf89ba..58a1099929 100644
--- a/lib/teiserver_web/templates/moderation/action/new_with_user.html.heex
+++ b/lib/teiserver_web/templates/moderation/action/new_with_user.html.heex
@@ -83,9 +83,22 @@ bsname = view_colour() %>
- {label(f, :expires, class: "control-label")}
- {text_input(f, :expires, class: "form-control", required: "required")}
- {error_tag(f, :expires)}
+ {label(f, :duration, class: "control-label")}
+ {text_input(f, :duration,
+ class: "form-control",
+ placeholder: "e.g. 7d, 24h, 1m, 1y"
+ )}
+
+ Timer starts when the user logs in and acknowledges the action.
+ Leave empty for a permanent ban.
+ Format: s
+ seconds, h
+ hours, d
+ days, m
+ months, y
+ years.
+
+ {error_tag(f, :duration)}
@@ -231,7 +244,7 @@ bsname = view_colour() %>
$('#action_reason').val('[CoC]() B6 - Spamming the main chat channel.');
$('#action_restriction_Room_chat').prop('checked', true);
$('#action_restriction_Warning_reminder').prop('checked', true);
- $('#action_expires').val('7 days');
+ $('#action_duration').val('7 days');
"
>
#main spam
@@ -245,7 +258,7 @@ bsname = view_colour() %>
onclick="
$('#action_restriction_All_lobbies').prop('checked', true);
$('#action_restriction_All_chat').prop('checked', true);
- $('#action_expires').val('5 days');
+ $('#action_duration').val('5 days');
$('#action_reason').val('[CoC]() C2 - Hate speech, calls for a person to harm themselves, real-life threats, celebration of horrific events are all regarded as unacceptable.');
"
>
@@ -258,7 +271,7 @@ bsname = view_colour() %>
$('#action_restriction_All_lobbies').prop('checked', true);
$('#action_restriction_All_chat').prop('checked', true);
$('#action_reason').val('[CoC]() B5 - Spectator cheating is completely unacceptable. Given the severity of this, any future attempts at cheating will result in a permanent ban, as will evasion of the suspension.')
- $('#action_expires').val('21 days');
+ $('#action_duration').val('21 days');
"
>
Spec cheating
@@ -269,7 +282,7 @@ bsname = view_colour() %>
$('#action_restriction_All_lobbies').prop('checked', true);
$('#action_restriction_All_chat').prop('checked', true);
$('#action_reason').val('[CoC]() B5 - Purposefully passing on consequential information to a game in progress via things like spectator chat (we understand mistakes happen occasionally) is obviously a form of cheating.')
- $('#action_expires').val('5 days');
+ $('#action_duration').val('5 days');
"
>
Passing Info
@@ -279,7 +292,7 @@ bsname = view_colour() %>
onclick="
$('#action_restriction_Login').prop('checked', true);
$('#action_reason').val('Permanent ban.');
- $('#action_expires').val('1100 years');
+ $('#action_duration').val('');
"
>
One-off account permaban
@@ -292,7 +305,7 @@ bsname = view_colour() %>
$('#action_restriction_All_chat').prop('checked', true);
$('#action_restriction_Note').prop('checked', true);
$('#action_reason').val('Permanent ban, possibly a previously banned user.');
- $('#action_expires').val('1100 years');
+ $('#action_duration').val('');
"
>
One-off account permaban (hidden, for ban evaders)
@@ -307,7 +320,7 @@ bsname = view_colour() %>
onclick="
$('#action_restriction_Warning_reminder').prop('checked', true);
$('#action_reason').val('[CoC]() C5 - Evading moderation by creating new accounts is not okay. Continued evasion will lead to a permanent ban.');
- $('#action_expires').val('1 day');
+ $('#action_duration').val('1 day');
"
>
Moderation evasion - 1st offense
@@ -318,7 +331,7 @@ bsname = view_colour() %>
onclick="
$('#action_restriction_Login').prop('checked', true);
$('#action_reason').val('[CoC]() C5 - Evading moderation, 2nd offense. Continued evasion will lead to a permanent ban.');
- $('#action_expires').val('1 day');
+ $('#action_duration').val('1 day');
"
>
Moderation evasion - 2nd offense
@@ -329,7 +342,7 @@ bsname = view_colour() %>
onclick="
$('#action_restriction_Login').prop('checked', true);
$('#action_reason').val('[CoC]() C5 - Evading moderation, 3rd offense. We may permanently ban the account if you do not stop trying to evade moderation.');
- $('#action_expires').val('3 days');
+ $('#action_duration').val('3 days');
"
>
Moderation evasion - 3rd offense
@@ -339,7 +352,7 @@ bsname = view_colour() %>
onclick={"
$('#action_restriction_Warning_reminder').prop('checked', true);
$('#action_reason').val('[CoC]() B5 - Smurf or alt-accounts are not allowed, if you need an exemption or you think there has been an error please use the #open-ticket discord channel to talk to the moderation team. The discord can be accessed at #{discord_link}');
- $('#action_expires').val('1 day');
+ $('#action_duration').val('1 day');
"}
>
Smurfing - 1st offense
@@ -350,7 +363,7 @@ bsname = view_colour() %>
onclick="
$('#action_restriction_Login').prop('checked', true);
$('#action_reason').val('[CoC]() B5 - You are continuing to create smurf/alt-accounts. This is against the code of conduct. We may permanently ban you if this continues.');
- $('#action_expires').val('1 day');
+ $('#action_duration').val('1 day');
"
>
Smurfing - 2nd offense
@@ -361,7 +374,7 @@ bsname = view_colour() %>
onclick="
$('#action_restriction_Login').prop('checked', true);
$('#action_reason').val('[CoC]() B5 - Smurfing/alt-accounting. 3rd offense, if you do it again we may permanently ban your account.');
- $('#action_expires').val('3 days');
+ $('#action_duration').val('3 days');
"
>
Smurfing - 3rd offense
@@ -414,7 +427,7 @@ bsname = view_colour() %>
$('#action_restriction_Matchmaking').prop('checked', true);
$('#action_restriction_Community').prop('checked', true);
$('#action_reason').val('[CoC]() B3 - **Nicknames and clan tags must not be offensive, inappropriate or contain politically charged phrases. Impersonation of other players and real-life figures is also forbidden. Player usernames must not contain references to modern political figures, contemporary movements, or historically controversial figures.** Please rename from `#{@user.name}` (use the Account button in the top right, then go to Recover/Change) to a name not in breach of the Code of Conduct and use the #open-ticket discord channel to notify the moderation team and have the restriction lifted. The discord can be accessed at #{discord_link} ');
- $('#action_expires').val('1100 years');
+ $('#action_duration').val('');
"}
>
Rename
@@ -425,7 +438,7 @@ bsname = view_colour() %>
onclick="
$('#action_restriction_Bridging').prop('checked', true);
$('#action_reason').val('Manual unbridging.');
- $('#action_expires').val('1100 years');
+ $('#action_duration').val('');
"
>
Unbridge
@@ -434,7 +447,7 @@ bsname = view_colour() %>
class={"btn btn-outline-#{bsname} btn-block"}
onclick="
$('#action_restriction_Note').prop('checked', true);
- $('#action_expires').val('1s');
+ $('#action_duration').val('1s');
"
>
+ internal note
@@ -530,20 +543,8 @@ bsname = view_colour() %>
{action.notes && action.notes |> String.slice(0..100)} |
{action.restrictions |> Enum.join(", ")} |
- {action.inserted_at |> date_to_str(format: :hms_or_ymd)} |
- <%= if action.expires do %>
- <%= if NaiveDateTime.compare(NaiveDateTime.utc_now(), action.expires) == :gt do %>
- Expired |
- <% else %>
- {action.expires |> date_to_str(format: :hms_or_ymd)} |
- <% end %>
-
- {duration_to_str(action.inserted_at, action.expires)} |
- <% else %>
-
- Permanent
- |
- <% end %>
+ {action.inserted_at |> date_to_str(format: :ymd_hms24)} |
+ <.action_status action={action} />
| Started: |
- {date_to_str(@action.inserted_at, format: :hms_or_ymd)} |
+ {date_to_str(@action.inserted_at, format: :ymd_hms24)} |
- <%= if @action.expires do %>
-
- | Expires: |
-
- <%= if NaiveDateTime.compare(NaiveDateTime.utc_now(), @action.expires) == :gt do %>
- Expired |
- <% else %>
- {@action.expires |> date_to_str(format: :hms_or_ymd)} |
- <% end %>
-
+
+ | Duration: |
+ {format_duration(@action.duration)} |
+
-
- | Duration: |
- {duration_to_str(@action.inserted_at, @action.expires)} |
-
- <% else %>
-
- | Expires: |
- Permanent |
-
+ <%= cond do %>
+ <% @action.expires != nil and NaiveDateTime.compare(NaiveDateTime.utc_now(), @action.expires) == :gt -> %>
+
+ | Expires: |
+ Expired ({duration_to_str(@action.inserted_at, @action.expires)}) |
+
+ <% @action.expires != nil -> %>
+
+ | Expires: |
+ {@action.expires |> date_to_str(format: :ymd_hms24)} |
+
+ <% @action.duration != nil -> %>
+
+ | Expires: |
+ Pending acknowledgement (timer starts on next login) |
+
+ <% true -> %>
+
+ | Expires: |
+ Permanent |
+
<% end %>
diff --git a/lib/teiserver_web/views/moderation/action_view.ex b/lib/teiserver_web/views/moderation/action_view.ex
index 57d03750c5..edddcf033d 100644
--- a/lib/teiserver_web/views/moderation/action_view.ex
+++ b/lib/teiserver_web/views/moderation/action_view.ex
@@ -12,4 +12,48 @@ defmodule TeiserverWeb.Moderation.ActionView do
@spec icon() :: String.t()
def icon, do: ActionLib.icon()
+
+ def action_status(assigns) do
+ ~H"""
+ <%= cond do %>
+ <% @action.expires != nil and NaiveDateTime.compare(NaiveDateTime.utc_now(), @action.expires) == :gt -> %>
+ | Expired |
+ {duration_to_str(@action.inserted_at, @action.expires)} |
+ <% @action.expires != nil -> %>
+ {@action.expires |> date_to_str(format: :ymd_hms24)} |
+ {duration_to_str(@action.inserted_at, @action.expires)} |
+ <% @action.duration != nil -> %>
+ Pending acknowledgement |
+ {format_duration(@action.duration)} |
+ <% true -> %>
+ Permanent |
+ <% end %>
+ """
+ end
+
+ @spec format_duration(integer() | nil) :: String.t()
+ def format_duration(nil), do: "Permanent"
+
+ def format_duration(seconds) do
+ cond do
+ seconds >= 86_400 * 365 -> "#{div(seconds, 86_400 * 365)} year(s)"
+ seconds >= 86_400 * 30 -> "#{div(seconds, 86_400 * 30)} month(s)"
+ seconds >= 86_400 -> "#{div(seconds, 86_400)} day(s)"
+ seconds >= 3_600 -> "#{div(seconds, 3_600)} hour(s)"
+ true -> "#{div(seconds, 60)} minute(s)"
+ end
+ end
+
+ @spec seconds_to_duration_input(integer() | nil) :: String.t()
+ def seconds_to_duration_input(nil), do: ""
+
+ def seconds_to_duration_input(seconds) do
+ cond do
+ rem(seconds, 86_400 * 365) == 0 -> "#{div(seconds, 86_400 * 365)}y"
+ rem(seconds, 86_400 * 30) == 0 -> "#{div(seconds, 86_400 * 30)}m"
+ rem(seconds, 86_400) == 0 -> "#{div(seconds, 86_400)}d"
+ rem(seconds, 3_600) == 0 -> "#{div(seconds, 3_600)}h"
+ true -> "#{seconds}s"
+ end
+ end
end
diff --git a/priv/repo/migrations/20260609103854_add_duration_to_moderation_actions.exs b/priv/repo/migrations/20260609103854_add_duration_to_moderation_actions.exs
new file mode 100644
index 0000000000..79193feb11
--- /dev/null
+++ b/priv/repo/migrations/20260609103854_add_duration_to_moderation_actions.exs
@@ -0,0 +1,10 @@
+defmodule Teiserver.Repo.Migrations.AddDurationToModerationActions do
+ use Ecto.Migration
+
+ def change do
+ alter table(:moderation_actions) do
+ add :duration, :bigint, null: true
+ modify :expires, :naive_datetime, null: true, from: {:naive_datetime, null: false}
+ end
+ end
+end
diff --git a/test/teiserver/helpers/date_helper_test.exs b/test/teiserver/helpers/date_helper_test.exs
index 65a7b7e178..35be9e0f7f 100644
--- a/test/teiserver/helpers/date_helper_test.exs
+++ b/test/teiserver/helpers/date_helper_test.exs
@@ -305,6 +305,24 @@ defmodule Teiserver.Helpers.DateHelperTest do
end
end
+ describe "date_to_str :ymd_hms24" do
+ test "formats date and time in 24-hour notation" do
+ dt = ~U[2026-06-18 15:45:40Z]
+ assert DateHelper.date_to_str(dt, format: :ymd_hms24) == "2026-06-18 15:45:40"
+ end
+
+ test "midnight is 00, not 12" do
+ dt = ~U[2026-06-18 00:00:00Z]
+ assert DateHelper.date_to_str(dt, format: :ymd_hms24) == "2026-06-18 00:00:00"
+ end
+
+ test "does not use 12-hour AM/PM format" do
+ dt = ~U[2026-06-18 15:45:40Z]
+ result = DateHelper.date_to_str(dt, format: :ymd_hms24)
+ refute result == "2026-06-18 03:45:40"
+ end
+ end
+
describe "human_input_to_datetime" do
@now ~U[2026-05-19 09:00:00Z]
diff --git a/test/teiserver/moderation/moderation_test.exs b/test/teiserver/moderation/moderation_test.exs
index 1705dbfc4d..8a0e8abe84 100644
--- a/test/teiserver/moderation/moderation_test.exs
+++ b/test/teiserver/moderation/moderation_test.exs
@@ -80,13 +80,13 @@ defmodule Teiserver.ModerationTest do
"reason" => "some reason",
"restrictions" => ["r1", "r2"],
"score_modifier" => "1000",
- "expires" => DateTime.utc_now()
+ "duration" => 7 * 86_400
}
@update_attrs %{
"reason" => "some updated reason",
"restrictions" => ["u1", "u2"],
"score_modifier" => "1500",
- "expires" => DateTime.utc_now()
+ "duration" => 14 * 86_400
}
@invalid_attrs %{"reason" => nil}
@@ -137,6 +137,53 @@ defmodule Teiserver.ModerationTest do
action = ModerationTestLib.action_fixture()
assert %Ecto.Changeset{} = Moderation.change_action(action)
end
+
+ test "expires is nil until user logs in" do
+ {:ok, action} =
+ @valid_attrs
+ |> Map.merge(%{"target_id" => GeneralTestLib.make_user().id})
+ |> Moderation.create_action()
+
+ assert action.duration == 7 * 86_400
+ assert is_nil(action.expires)
+
+ # Action should appear in "Pending only" query, not in "Unexpired only"
+ pending =
+ Moderation.list_actions(search: [target_id: action.target_id, expiry: "Pending only"])
+
+ unexpired =
+ Moderation.list_actions(search: [target_id: action.target_id, expiry: "Unexpired only"])
+
+ assert Enum.any?(pending, &(&1.id == action.id))
+ refute Enum.any?(unexpired, &(&1.id == action.id))
+ end
+
+ test "expires is set after user logs in" do
+ {:ok, action} =
+ @valid_attrs
+ |> Map.merge(%{"target_id" => GeneralTestLib.make_user().id})
+ |> Moderation.create_action()
+
+ assert is_nil(action.expires)
+
+ # Simulate coordinator login: set expires = now + duration
+ now = DateTime.utc_now()
+ expires = DateTime.add(now, action.duration, :second)
+ {:ok, updated} = Moderation.update_action(action, %{"expires" => expires})
+
+ expected_expires = expires |> DateTime.to_naive() |> NaiveDateTime.truncate(:second)
+ assert updated.expires == expected_expires
+
+ # Action should now appear in "Unexpired only", not in "Pending only"
+ pending =
+ Moderation.list_actions(search: [target_id: action.target_id, expiry: "Pending only"])
+
+ unexpired =
+ Moderation.list_actions(search: [target_id: action.target_id, expiry: "Unexpired only"])
+
+ refute Enum.any?(pending, &(&1.id == action.id))
+ assert Enum.any?(unexpired, &(&1.id == action.id))
+ end
end
describe "bans" do
diff --git a/test/teiserver_web/controllers/moderation/action_controller_test.exs b/test/teiserver_web/controllers/moderation/action_controller_test.exs
index 0e3868c8ec..45f71aca90 100644
--- a/test/teiserver_web/controllers/moderation/action_controller_test.exs
+++ b/test/teiserver_web/controllers/moderation/action_controller_test.exs
@@ -16,10 +16,14 @@ defmodule TeiserverWeb.Moderation.ActionControllerTest do
@create_attrs %{
reason: "some name",
restrictions: %{"Login" => "Login"},
- expires: "1 day",
+ duration: "1d",
score_modifier: "10000"
}
- @update_attrs %{reason: "some updated name", restrictions: %{"Warning" => "Warning"}}
+ @update_attrs %{
+ reason: "some updated name",
+ restrictions: %{"Warning" => "Warning"},
+ duration: "7d"
+ }
@invalid_attrs %{reason: nil, restrictions: %{}}
describe "index" do
@@ -160,7 +164,7 @@ defmodule TeiserverWeb.Moderation.ActionControllerTest do
describe "halt action" do
test "halts chosen action", %{conn: conn} do
action = ModerationTestLib.action_fixture()
- assert NaiveDateTime.compare(action.expires, NaiveDateTime.utc_now()) == :gt
+ assert is_nil(action.expires)
conn = put(conn, Routes.moderation_action_path(conn, :halt, action.id))
assert redirected_to(conn) == Routes.moderation_action_path(conn, :show, action)
|