From c1e11deabab43eedea00009bf5cd0b9e2abe4772 Mon Sep 17 00:00:00 2001 From: Taylor Downs Date: Tue, 2 Jun 2026 12:08:13 +0200 Subject: [PATCH] track MAU --- lib/impact_tracker/project.ex | 38 +++++- lib/impact_tracker/submission.ex | 75 +++++++--- ...1000000_add_no_of_monthly_active_users.exs | 13 ++ test/impact_tracker/project_test.exs | 79 +++++++++++ test/impact_tracker/submission_test.exs | 129 +++++++++++++++++- 5 files changed, 304 insertions(+), 30 deletions(-) create mode 100644 priv/repo/migrations/20260601000000_add_no_of_monthly_active_users.exs diff --git a/lib/impact_tracker/project.ex b/lib/impact_tracker/project.ex index 04bb50f..32227b7 100644 --- a/lib/impact_tracker/project.ex +++ b/lib/impact_tracker/project.ex @@ -16,32 +16,56 @@ defmodule ImpactTracker.Project do field :cleartext_uuid, Ecto.UUID field :hashed_uuid, :string field :no_of_active_users, :integer + field :no_of_monthly_active_users, :integer field :no_of_users, :integer timestamps() end def v2_changeset(project, params) do - cast_attrs = [ - :cleartext_uuid, - :hashed_uuid, - :no_of_active_users, - :no_of_users - ] + changeset(project, params, include_monthly_active_users: false) + end + + # Version 3 adds a 30-day `no_of_monthly_active_users` alongside the existing + # 90-day `no_of_active_users`. + def v3_changeset(project, params) do + changeset(project, params, include_monthly_active_users: true) + end + + defp changeset(project, params, include_monthly_active_users: include_mau) do + mau_attrs = if include_mau, do: [:no_of_monthly_active_users], else: [] - required_attrs = [:hashed_uuid, :no_of_active_users, :no_of_users] + cast_attrs = + [ + :cleartext_uuid, + :hashed_uuid, + :no_of_active_users, + :no_of_users + ] ++ mau_attrs + + required_attrs = + [:hashed_uuid, :no_of_active_users, :no_of_users] ++ mau_attrs project |> cast(params, cast_attrs) |> validate_required(required_attrs) |> validate_number(:no_of_active_users, greater_than_or_equal_to: 0) |> validate_number(:no_of_users, greater_than_or_equal_to: 0) + |> maybe_validate_monthly_active_users(include_mau) |> validate_hashed_uuid() # Note - at the moment, there does not appear to be a cost-effective way # to validate that the `workflows` element is present |> cast_assoc(:workflows, with: &Workflow.v2_changeset/2) end + defp maybe_validate_monthly_active_users(changeset, true) do + validate_number(changeset, :no_of_monthly_active_users, + greater_than_or_equal_to: 0 + ) + end + + defp maybe_validate_monthly_active_users(changeset, false), do: changeset + defp validate_hashed_uuid(changeset = %{changes: %{cleartext_uuid: cleartext}}) do validate_change(changeset, :hashed_uuid, fn _, hash -> if hash == Base.encode16(:crypto.hash(:sha256, cleartext)) do diff --git a/lib/impact_tracker/submission.ex b/lib/impact_tracker/submission.ex index b62f91f..4527283 100644 --- a/lib/impact_tracker/submission.ex +++ b/lib/impact_tracker/submission.ex @@ -10,7 +10,7 @@ defmodule ImpactTracker.Submission do alias ImpactTracker.Project # When adding new versions, update this. - @supported_versions ["1", "2"] + @supported_versions ["1", "2", "3"] @primary_key {:id, :binary_id, autogenerate: true} schema "submissions" do @@ -21,6 +21,7 @@ defmodule ImpactTracker.Submission do field :instance_id, Ecto.UUID field :lightning_version, :string field :no_of_active_users, :integer + field :no_of_monthly_active_users, :integer field :no_of_users, :integer field :operating_system, :string field :report_date, :date @@ -48,42 +49,70 @@ defmodule ImpactTracker.Submission do end defp versioned_setup(changeset = %{changes: %{version: "2"}}, all_attrs) do + build_submission(changeset, all_attrs, include_monthly_active_users: false) + end + + # Version 3 adds a 30-day `no_of_monthly_active_users` (true MAU) alongside + # the existing 90-day `no_of_active_users`, at both instance and project level. + defp versioned_setup(changeset = %{changes: %{version: "3"}}, all_attrs) do + build_submission(changeset, all_attrs, include_monthly_active_users: true) + end + + defp build_submission(changeset, all_attrs, + include_monthly_active_users: include_mau + ) do submission_attrs = all_attrs |> extract_submission_attrs() - cast_attrs = [ - :country, - :generated_at, - :lightning_version, - :no_of_active_users, - :no_of_users, - :operating_system, - :region, - :report_date, - :version - ] - - required_attrs = [ - :generated_at, - :lightning_version, - :no_of_active_users, - :no_of_users, - :operating_system, - :report_date - ] + mau_attrs = if include_mau, do: [:no_of_monthly_active_users], else: [] + + cast_attrs = + [ + :country, + :generated_at, + :lightning_version, + :no_of_active_users, + :no_of_users, + :operating_system, + :region, + :report_date, + :version + ] ++ mau_attrs + + required_attrs = + [ + :generated_at, + :lightning_version, + :no_of_active_users, + :no_of_users, + :operating_system, + :report_date + ] ++ mau_attrs + + project_changeset = + if include_mau, do: &Project.v3_changeset/2, else: &Project.v2_changeset/2 changeset |> cast(submission_attrs, cast_attrs) |> validate_required(required_attrs) |> validate_number(:no_of_active_users, greater_than_or_equal_to: 0) |> validate_number(:no_of_users, greater_than_or_equal_to: 0) + |> maybe_validate_monthly_active_users(include_mau) |> unique_constraint( [:instance_id, :report_date], message: "instance already has a submission for this date" ) - |> cast_assoc(:projects, with: &Project.v2_changeset/2) + |> cast_assoc(:projects, with: project_changeset) end + defp maybe_validate_monthly_active_users(changeset, true) do + validate_number(changeset, :no_of_monthly_active_users, + greater_than_or_equal_to: 0 + ) + end + + defp maybe_validate_monthly_active_users(changeset, false), do: changeset + defp extract_submission_attrs(attrs) do %{ country: attrs |> extract_attr("country"), @@ -91,6 +120,8 @@ defmodule ImpactTracker.Submission do lightning_version: attrs |> extract_attr("instance", "version"), no_of_active_users: attrs |> extract_attr("instance", "no_of_active_users"), + no_of_monthly_active_users: + attrs |> extract_attr("instance", "no_of_monthly_active_users"), no_of_users: attrs |> extract_attr("instance", "no_of_users"), operating_system: attrs |> extract_attr("instance", "operating_system"), projects: attrs |> extract_attr("projects"), diff --git a/priv/repo/migrations/20260601000000_add_no_of_monthly_active_users.exs b/priv/repo/migrations/20260601000000_add_no_of_monthly_active_users.exs new file mode 100644 index 0000000..bf80865 --- /dev/null +++ b/priv/repo/migrations/20260601000000_add_no_of_monthly_active_users.exs @@ -0,0 +1,13 @@ +defmodule ImpactTracker.Repo.Migrations.AddNoOfMonthlyActiveUsers do + use Ecto.Migration + + def change do + alter table(:submissions) do + add :no_of_monthly_active_users, :integer, null: true + end + + alter table(:projects) do + add :no_of_monthly_active_users, :integer, null: true + end + end +end diff --git a/test/impact_tracker/project_test.exs b/test/impact_tracker/project_test.exs index 86b1327..45f6231 100644 --- a/test/impact_tracker/project_test.exs +++ b/test/impact_tracker/project_test.exs @@ -204,6 +204,81 @@ defmodule ImpactTracker.ProjectTest do end end + describe "v3_changeset/2" do + setup do + %{data: build_v3_project_data()} + end + + test "returns a valid changeset including no_of_monthly_active_users", %{ + data: data + } do + changeset = %Project{} |> Project.v3_changeset(data) + + assert %Changeset{valid?: true, changes: changes} = changeset + + assert( + %{ + no_of_active_users: 7, + no_of_monthly_active_users: 4, + no_of_users: 10 + } = changes + ) + + assert changes.workflows |> Enum.count() == 2 + end + + test "validates the presence of no_of_monthly_active_users", %{data: data} do + changeset = + %Project{} + |> Project.v3_changeset( + data + |> remove_data("no_of_monthly_active_users") + ) + + assert %Changeset{valid?: false, errors: errors} = changeset + + assert( + [ + no_of_monthly_active_users: + {"can't be blank", [{:validation, :required}]} + ] = errors + ) + end + + test "validates that no_of_monthly_active_users >= 0", %{data: data} do + changeset = + %Project{} + |> Project.v3_changeset( + data + |> modify_data("no_of_monthly_active_users", -1) + ) + + assert %Changeset{valid?: false, errors: errors} = changeset + + assert( + [ + no_of_monthly_active_users: { + "must be greater than or equal to %{number}", + [ + {:validation, :number}, + {:kind, :greater_than_or_equal_to}, + {:number, 0} + ] + } + ] = errors + ) + + changeset = + %Project{} + |> Project.v3_changeset( + data + |> modify_data("no_of_monthly_active_users", 0) + ) + + assert %Changeset{valid?: true} = changeset + end + end + defp build_project_data do uuid = generate_uuid() @@ -216,6 +291,10 @@ defmodule ImpactTracker.ProjectTest do } end + defp build_v3_project_data do + build_project_data() |> Map.put("no_of_monthly_active_users", 4) + end + defp build_workflow_data do uuid_1 = generate_uuid() uuid_2 = generate_uuid() diff --git a/test/impact_tracker/submission_test.exs b/test/impact_tracker/submission_test.exs index 949f637..5a291bb 100644 --- a/test/impact_tracker/submission_test.exs +++ b/test/impact_tracker/submission_test.exs @@ -283,7 +283,8 @@ defmodule ImpactTracker.SubmissionTest do assert [ version: - {"is invalid", [{:validation, :inclusion}, {:enum, ["1", "2"]}]} + {"is invalid", + [{:validation, :inclusion}, {:enum, ["1", "2", "3"]}]} ] = errors end @@ -358,6 +359,95 @@ defmodule ImpactTracker.SubmissionTest do end end + describe ".new/3 version 3" do + setup do + %{data: build_v3_submission_data()} + end + + test "generates a valid changeset for the submission", %{data: data} do + changeset = + %Submission{} + |> Submission.new(data, build_geolocation_data()) + + assert %Changeset{valid?: true, changes: changes} = changeset + + assert( + %{ + no_of_active_users: 7, + no_of_monthly_active_users: 5, + no_of_users: 10, + version: "3" + } = changes + ) + end + + test "captures no_of_monthly_active_users for the projects", %{data: data} do + changeset = + %Submission{} + |> Submission.new(data, build_geolocation_data()) + + %{changes: %{projects: projects}} = changeset + + assert( + [ + %{changes: %{no_of_monthly_active_users: 2}}, + %{changes: %{no_of_monthly_active_users: 3}} + ] = projects + ) + end + + test "validates presence of `no_of_monthly_active_users`", %{data: data} do + changeset = + %Submission{} + |> Submission.new( + data |> remove_data("instance", "no_of_monthly_active_users"), + build_geolocation_data() + ) + + assert %Changeset{valid?: false, errors: errors} = changeset + + assert [ + no_of_monthly_active_users: { + "can't be blank", + [{:validation, :required}] + } + ] = errors + end + + test "validates that no_of_monthly_active_users is >= 0", %{data: data} do + changeset = + %Submission{} + |> Submission.new( + data + |> modify_submission_data("instance", "no_of_monthly_active_users", -1), + build_geolocation_data() + ) + + assert %Changeset{valid?: false, errors: errors} = changeset + + assert [ + no_of_monthly_active_users: { + "must be greater than or equal to %{number}", + [ + {:validation, :number}, + {:kind, :greater_than_or_equal_to}, + {:number, 0} + ] + } + ] = errors + + changeset = + %Submission{} + |> Submission.new( + data + |> modify_submission_data("instance", "no_of_monthly_active_users", 0), + build_geolocation_data() + ) + + assert %Changeset{valid?: true} = changeset + end + end + defp build_submission_data do %{ "generated_at" => "2024-02-06T12:50:37.245897Z", @@ -373,6 +463,22 @@ defmodule ImpactTracker.SubmissionTest do } end + defp build_v3_submission_data do + %{ + "generated_at" => "2024-02-06T12:50:37.245897Z", + "instance" => %{ + "operating_system" => "linux", + "no_of_active_users" => 7, + "no_of_monthly_active_users" => 5, + "no_of_users" => 10, + "version" => "2.0.0rc1" + }, + "projects" => build_v3_project_data(), + "report_date" => "2024-02-05", + "version" => "3" + } + end + defp modify_submission_data(submission, key, value) do submission |> Map.merge(%{key => value}) end @@ -416,5 +522,26 @@ defmodule ImpactTracker.SubmissionTest do ] end + defp build_v3_project_data do + [ + %{ + "cleartext_uuid" => nil, + "hashed_uuid" => hash("foo"), + "no_of_active_users" => 3, + "no_of_monthly_active_users" => 2, + "no_of_users" => 10, + "workflows" => [] + }, + %{ + "cleartext_uuid" => nil, + "hashed_uuid" => hash("bar"), + "no_of_active_users" => 4, + "no_of_monthly_active_users" => 3, + "no_of_users" => 30, + "workflows" => [] + } + ] + end + defp hash(uuid), do: Base.encode16(:crypto.hash(:sha256, uuid)) end