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)