diff --git a/config/config.exs b/config/config.exs index 6a6476c76..0995b6e60 100644 --- a/config/config.exs +++ b/config/config.exs @@ -33,7 +33,9 @@ config :algora, Oban, github_og_image: 5, notify_bounty: 1, notify_tip_intent: 1, - notify_claim: 1 + notify_claim: 1, + activity_notifier: 1, + activity_mailer: 1 ] # Configures the mailer diff --git a/config/test.exs b/config/test.exs index b4a4339ee..fc05c0f97 100644 --- a/config/test.exs +++ b/config/test.exs @@ -47,3 +47,7 @@ config :algora, cloudflare_tunnel: System.get_env("CLOUDFLARE_TUNNEL"), swift_mode: false, auto_start_pollers: System.get_env("AUTO_START_POLLERS") == "true" + +config :algora, :stripe, + test_customer_id: System.get_env("STRIPE_TEST_CUSTOMER_ID"), + test_account_id: System.get_env("STRIPE_TEST_ACCOUNT_ID") diff --git a/coveralls.json b/coveralls.json new file mode 100644 index 000000000..61853132b --- /dev/null +++ b/coveralls.json @@ -0,0 +1,29 @@ +{ + "skip_files": [ + "lib/algora/integrations", + "lib/algora/shared/*", + "lib/algora/admin/admin.ex", + "lib/algora_web/components", + "lib/algora_web", + "lib/mix/tasks", + "priv/", + "test/support" + ], + "default_stop_words": [ + "defmodule", + "defrecord", + "defimpl", + "def.+(.+\/\/.+).+do", + "typed_schema", + "use .+" + ], + + "custom_stop_words": [ + ], + + "coverage_options": { + "treat_no_relevant_lines_as_covered": true, + "output_dir": "cover/", + "html_filter_full_covered": true + } +} diff --git a/lib/algora/accounts/accounts.ex b/lib/algora/accounts/accounts.ex index 532ec12be..5658be6b4 100644 --- a/lib/algora/accounts/accounts.ex +++ b/lib/algora/accounts/accounts.ex @@ -310,6 +310,16 @@ defmodule Algora.Accounts do Repo.one(query) end + def get_user_by_handle(handle) do + query = + from(u in User, + where: u.handle == ^handle, + select: u + ) + + Repo.one(query) + end + def get_access_token(%User{} = user) do case Repo.one(from(i in Identity, where: i.user_id == ^user.id and i.provider == "github")) do %Identity{provider_token: token} -> {:ok, token} diff --git a/lib/algora/accounts/schemas/identity.ex b/lib/algora/accounts/schemas/identity.ex index ea80ee1c8..586e03ec1 100644 --- a/lib/algora/accounts/schemas/identity.ex +++ b/lib/algora/accounts/schemas/identity.ex @@ -4,6 +4,7 @@ defmodule Algora.Accounts.Identity do alias Algora.Accounts.Identity alias Algora.Accounts.User + alias Algora.Activities.Activity @derive {Inspect, except: [:provider_token, :provider_meta]} typed_schema "identities" do @@ -17,6 +18,8 @@ defmodule Algora.Accounts.Identity do belongs_to :user, User + has_many :activities, {"identity_activities", Activity}, foreign_key: :assoc_id + timestamps() end @@ -40,6 +43,7 @@ defmodule Algora.Accounts.Identity do :provider_name, :provider_id ]) + |> Activity.put_activity(%Identity{}, %{type: :identity_created}) |> generate_id() |> validate_required([:provider_token, :provider_email, :provider_name, :provider_id]) |> validate_length(:provider_meta, max: 10_000) @@ -66,6 +70,7 @@ defmodule Algora.Accounts.Identity do :provider_name, :provider_id ]) + |> Activity.put_activity(%Identity{}, %{type: :identity_created}) |> generate_id() |> validate_required([:provider_token, :provider_email, :provider_name, :provider_id]) |> validate_length(:provider_meta, max: 10_000) diff --git a/lib/algora/accounts/schemas/user.ex b/lib/algora/accounts/schemas/user.ex index 6ac25d21f..3290fc979 100644 --- a/lib/algora/accounts/schemas/user.ex +++ b/lib/algora/accounts/schemas/user.ex @@ -4,7 +4,9 @@ defmodule Algora.Accounts.User do alias Algora.Accounts.Identity alias Algora.Accounts.User + alias Algora.Activities.Activity alias Algora.Bounties.Bounty + alias Algora.Bounties.Tip alias Algora.Contracts.Contract alias Algora.MoneyUtils alias Algora.Organizations.Member @@ -76,6 +78,9 @@ defmodule Algora.Accounts.User do has_many :members, Member, foreign_key: :org_id has_many :owned_bounties, Bounty, foreign_key: :owner_id has_many :created_bounties, Bounty, foreign_key: :creator_id + has_many :owned_tips, Tip, foreign_key: :owner_id + has_many :created_tips, Tip, foreign_key: :creator_id + has_many :received_tips, Tip, foreign_key: :recipient_id has_many :attempts, Algora.Bounties.Attempt has_many :claims, Algora.Bounties.Claim has_many :projects, Algora.Projects.Project @@ -85,6 +90,7 @@ defmodule Algora.Accounts.User do has_many :connected_installations, Installation, foreign_key: :connected_user_id has_many :contractor_contracts, Contract, foreign_key: :contractor_id has_many :client_contracts, Contract, foreign_key: :client_id + has_many :activities, {"user_activities", Activity}, foreign_key: :assoc_id has_one :customer, Algora.Payments.Customer, foreign_key: :user_id @@ -295,6 +301,10 @@ defmodule Algora.Accounts.User do |> unique_constraint([:provider, :provider_id]) end + def is_admin_changeset(user, is_admin) do + cast(user, %{is_admin: is_admin}, [:is_admin]) + end + def validate_timezone(changeset) do validate_inclusion(changeset, :timezone, Tzdata.zone_list()) end diff --git a/lib/algora/activities/activities.ex b/lib/algora/activities/activities.ex new file mode 100644 index 000000000..94ed48f98 --- /dev/null +++ b/lib/algora/activities/activities.ex @@ -0,0 +1,328 @@ +defmodule Algora.Activities do + @moduledoc false + import Ecto.Query + + alias Algora.Accounts.Identity + alias Algora.Accounts.User + alias Algora.Activities.Activity + alias Algora.Activities.Router + alias Algora.Activities.Views + alias Algora.Bounties.Bounty + alias Algora.Repo + + @schema_from_table %{ + identity_activities: Identity, + user_activities: Algora.Accounts.User, + attempt_activities: Algora.Bounties.Attempt, + bonus_activities: Algora.Bounties.Bonus, + bounty_activities: Bounty, + claim_activities: Algora.Bounties.Claim, + tip_activities: Algora.Bounties.Tip, + message_activities: Algora.Chat.Message, + thread_activities: Algora.Chat.Thread, + contract_activities: Algora.Contracts.Contract, + timesheet_activities: Algora.Contracts.Timesheet, + application_activities: Algora.Jobs.Application, + job_activities: Algora.Jobs.Job, + account_activities: Algora.Payments.Account, + customer_activities: Algora.Payments.Customer, + payment_method_activities: Algora.Payments.PaymentMethod, + platform_transaction_activities: Algora.Payments.PlatformTransaction, + transaction_activities: Algora.Payments.Transaction, + project_activities: Algora.Projects.Project, + review_activities: Algora.Reviews.Project, + installation_activities: Algora.Workplace.Installation, + ticket_activities: Algora.Workspace.Ticket, + repository_activities: Algora.Workspace.Repository + } + + @table_from_user_relation %{ + # attempts: "attempt_activities", + claims: "claim_activities", + client_contracts: "contract_activities", + connected_installations: "installation_activities", + contractor_contracts: "contract_activities", + created_bounties: "bounty_activities", + # owned_bounties: "bounty_activities", + created_tips: "tip_activities", + # owned_tips: "tip_activities", + received_tips: "tip_activities", + identities: "identity_activities", + owned_installations: "installation_activities", + # projects: "project_activities", + repositories: "repository_activities", + transactions: "transaction_activities" + } + + @table_from_schema Map.new(@schema_from_table, &{elem(&1, 1), elem(&1, 0)}) + @tables Map.keys(@schema_from_table) + @user_attributes Map.keys(@table_from_user_relation) + + def schema_from_table(name) when is_binary(name), do: name |> String.to_atom() |> schema_from_table() + + def schema_from_table(name) when is_atom(name) do + Map.fetch!(@schema_from_table, name) + end + + def table_from_schema(name) when is_binary(name), do: name |> String.to_atom() |> table_from_schema() + + def table_from_schema(name) when is_atom(name) do + Map.fetch!(@table_from_schema, name) + end + + def table_from_user_relation(table) do + Map.fetch!(@table_from_user_relation, table) + end + + def tables, do: @tables + def user_attributes, do: @user_attributes + + def base_query do + [head | tail] = @tables + query = head |> to_string() |> base_query() + + Enum.reduce(tail, query, fn table_path, acc -> + new_query = base_query(table_path) + union_all(new_query, ^acc) + end) + end + + def base_query(table_name) when is_atom(table_name) do + table_name |> to_string() |> base_query() + end + + def base_query(table_name) when is_binary(table_name) do + base = from(e in {table_name, Activity}) + + from(u in subquery(base), + select_merge: %{ + id: u.id, + type: u.type, + assoc_id: u.assoc_id, + assoc_name: ^table_name + } + ) + end + + def base_query_for_user(user_id) do + [head | tail] = @user_attributes + first_query = base_query_for_user(user_id, head) + + Enum.reduce(tail, first_query, fn relation_name, acc -> + new_query = base_query_for_user(user_id, relation_name) + union_all(new_query, ^acc) + end) + end + + def base_query_for_user(user_id, relation_name) do + table_name = table_from_user_relation(relation_name) + + from u in User, + where: u.id == ^user_id, + join: c in assoc(u, ^relation_name), + join: a in assoc(c, :activities), + select: %{ + id: a.id, + type: a.type, + assoc_id: a.assoc_id, + assoc_name: ^table_name, + inserted_at: a.inserted_at + } + end + + def all(table_name) when is_binary(table_name) do + table_name + |> base_query() + |> order_by(desc: :inserted_at) + |> Repo.all() + end + + def all(target) when is_map(target) do + target + |> Ecto.assoc(:activities) + |> order_by(desc: :inserted_at) + |> Repo.all() + end + + def all do + base_query() + |> order_by(fragment("inserted_at DESC")) + |> limit(40) + |> all_with_assoc() + end + + def all_for_user(user_id) do + user_id + |> base_query_for_user() + |> order_by(fragment("inserted_at DESC")) + |> limit(40) + |> all_with_assoc() + end + + def insert(target, activity) do + target + |> Activity.build_activity(activity) + |> Algora.Repo.insert() + end + + def all_with_assoc(query) do + activities = Repo.all(query) + source = Dataloader.Ecto.new(Algora.Repo) + dataloader = Dataloader.add_source(Dataloader.new(), :db, source) + + loader = + activities + |> Enum.reduce(dataloader, fn activity, loader -> + schema = schema_from_table(activity.assoc_name) + Dataloader.load(loader, :db, schema, activity.assoc_id) + end) + |> Dataloader.run() + + Enum.map(activities, fn activity -> + schema = schema_from_table(activity.assoc_name) + assoc = Dataloader.get(loader, :db, schema, activity.assoc_id) + Map.put(activity, :assoc, assoc) + end) + end + + def get(table, id) do + assoc_query = + from t in schema_from_table(table), + where: parent_as(:activity).assoc_id == t.id + + query = + from a in table, + as: :activity, + where: a.id == ^id, + inner_lateral_join: t in subquery(assoc_query), + on: true, + select: %{ + id: a.id, + type: a.type, + assoc_id: a.assoc_id, + assoc_name: ^table, + assoc: t, + notify_users: a.notify_users, + visibility: a.visibility, + template: a.template, + meta: a.meta, + changes: a.changes, + trace_id: a.trace_id, + previous_event_id: a.previous_event_id, + inserted_at: a.inserted_at, + updated_at: a.updated_at + } + + struct(Activity, Algora.Repo.one(query)) + end + + def get_with_preloaded_assoc(table, id) do + schema = schema_from_table(table) + + with %{assoc_id: assoc_id} = activity <- get(table, id), + assoc when is_map(assoc) <- get_preloaded_assoc(schema, assoc_id) do + Map.put(activity, :assoc, assoc) + end + end + + def get_preloaded_assoc(schema, assoc_id) do + query = + if Kernel.function_exported?(schema, :preload, 1) do + schema.preload(assoc_id) + else + from a in schema, where: a.id == ^assoc_id + end + + Algora.Repo.one(query) + end + + def assoc_url(table, id) do + table |> get(id) |> Router.route() + end + + def subscribe do + Phoenix.PubSub.subscribe(Algora.PubSub, "activities") + end + + def subscribe(schema) when is_atom(schema) do + schema |> schema_from_table() |> subscribe() + end + + def subscribe_table(table) when is_binary(table) do + Phoenix.PubSub.subscribe(Algora.PubSub, "activity:table:#{table}") + end + + def subscribe_user(user_id) when is_binary(user_id) do + Phoenix.PubSub.subscribe(Algora.PubSub, "activity:users:#{user_id}") + end + + def broadcast(%{notify_users: []}), do: [] + + def broadcast(%{notify_users: user_ids} = activity) do + :ok = Phoenix.PubSub.broadcast(Algora.PubSub, "activities", activity) + :ok = Phoenix.PubSub.broadcast(Algora.PubSub, "activity:table:#{activity.assoc_name}", activity) + + users_query = + from u in Algora.Accounts.User, + where: u.id in ^user_ids, + select: u + + users_query + |> Algora.Repo.all() + |> Enum.reduce([], fn user, not_online -> + # TODO setup notification preferences + :ok = Phoenix.PubSub.broadcast(Algora.PubSub, "activity:users:#{user.id}", activity) + [user | not_online] + end) + end + + def notify_users(_activity, []), do: :ok + + def notify_users(activity, users_to_notify) do + title = Views.render(activity, :title) + body = Views.render(activity, :txt) + + users_to_notify + |> Enum.reduce([], fn + %{name: display_name, email: email, id: id}, acc -> + changeset = + Algora.Activities.SendEmail.changeset(%{ + title: title, + body: body, + user_id: id, + activity_id: activity.id, + activity_type: activity.type, + activity_table: activity.assoc_name, + name: display_name, + email: email + }) + + [changeset | acc] + + _user, acc -> + acc + end) + |> Oban.insert_all() + end + + def redirect_url_for_activity(activity) do + slug = + activity.assoc_name + |> to_string() + |> String.replace("_activities", "") + + "a/#{slug}/#{activity.id}" + end + + def external_url(activity) do + path = redirect_url_for_activity(activity) + "#{AlgoraWeb.Endpoint.url()}/#{path}" + end + + def activity_type_to_name(type) do + type + |> to_string() + |> String.split("_") + |> Enum.map_join(" ", &String.capitalize(&1)) + end +end diff --git a/lib/algora/activities/jobs/notifier.ex b/lib/algora/activities/jobs/notifier.ex new file mode 100644 index 000000000..8adad8b8f --- /dev/null +++ b/lib/algora/activities/jobs/notifier.ex @@ -0,0 +1,38 @@ +defmodule Algora.Activities.Notifier do + @moduledoc false + use Oban.Worker, + queue: :activity_notifier, + max_attempts: 1 + + alias Algora.Activities + + # unique: [period: 30] + + def changeset(activity, target) do + case Activities.table_from_schema(target.__meta__.schema) do + nil -> + :error + + table when is_atom(table) -> + new(%{activity_id: activity.id, target_id: target.id, table_name: table}) + end + end + + @impl Oban.Worker + def perform(%Oban.Job{args: args}) do + case args do + %{ + "activity_id" => activity_id, + "table_name" => table + } + when is_binary(table) -> + activity = Activities.get_with_preloaded_assoc(table, activity_id) + users_to_notify = Activities.broadcast(activity) + Activities.notify_users(activity, users_to_notify) + :ok + + _args -> + :error + end + end +end diff --git a/lib/algora/activities/jobs/send_email.ex b/lib/algora/activities/jobs/send_email.ex new file mode 100644 index 000000000..350494d2a --- /dev/null +++ b/lib/algora/activities/jobs/send_email.ex @@ -0,0 +1,36 @@ +defmodule Algora.Activities.SendEmail do + @moduledoc false + use Oban.Worker, + queue: :activity_mailer, + max_attempts: 1, + tags: ["email", "activities"] + + alias Swoosh.Email + + @from_name "Algora" + @from_email "info@algora.io" + + # unique: [period: 30] + + def changeset(attrs) do + new(attrs) + end + + @impl Oban.Worker + def perform(%Oban.Job{args: args}) do + case args do + %{"email" => email, "name" => name, "title" => subject, "body" => body} -> + email = + Email.new() + |> Email.to({name, email}) + |> Email.from({@from_name, @from_email}) + |> Email.subject(subject) + |> Email.text_body(body) + + Algora.Mailer.deliver(email) + + _args -> + :discard + end + end +end diff --git a/lib/algora/activities/router.ex b/lib/algora/activities/router.ex new file mode 100644 index 000000000..719d85772 --- /dev/null +++ b/lib/algora/activities/router.ex @@ -0,0 +1,14 @@ +defmodule Algora.Activities.Router do + alias Algora.Accounts.Identity + alias Algora.Bounties.Bounty + + def route(%{assoc: %Bounty{owner: user}}), do: {:ok, "/org/#{user.handle}/bounties"} + + def route(%{assoc: %Identity{user: %{type: :individual} = user}}), do: {:ok, "/@/#{user.handle}"} + + def route(%{assoc: %Identity{user: %{type: :organization} = user}}), do: {:ok, "/org/#{user.handle}"} + + def route(_activity) do + {:error, :not_found} + end +end diff --git a/lib/algora/activities/schemas/activity.ex b/lib/algora/activities/schemas/activity.ex new file mode 100644 index 000000000..9111da076 --- /dev/null +++ b/lib/algora/activities/schemas/activity.ex @@ -0,0 +1,98 @@ +defmodule Algora.Activities.Activity do + @moduledoc false + use Algora.Schema + + require Protocol + + @activity_types ~w{ + contract_paid + contract_prepaid + contract_created + contract_renewed + identity_created + bounty_awarded + bounty_posted + bounty_repriced + claim_submitted + claim_approved + tip_awarded + }a + + typed_schema "activities" do + field :assoc_id, :string + field :type, Ecto.Enum, values: @activity_types + field :visibility, Ecto.Enum, values: [:public, :private, :internal], default: :internal + field :template, :string + field :meta, :map, default: %{} + field :changes, :map, default: %{} + field :trace_id, :string + field :notify_users, {:array, :string}, default: [] + field :assoc_name, :string, virtual: true + field :assoc, :map, virtual: true + + belongs_to :user, Algora.Accounts.User + belongs_to :previous_event, __MODULE__ + + timestamps() + end + + def types, do: @activity_types + + @doc false + def changeset(activity, attrs) do + activity + |> cast(attrs, [ + :type, + :visibility, + :template, + :meta, + :changes, + :trace_id, + :user_id, + :previous_event_id, + :notify_users + ]) + |> validate_required([:type]) + |> foreign_key_constraint(:assoc_id) + |> foreign_key_constraint(:user_id) + |> foreign_key_constraint(:previous_event_id) + |> generate_id() + end + + def build_activity(target, %{meta: %struct{}} = activity) when struct in [Stripe.Error] do + build_activity(target, %{activity | meta: Algora.Util.normalize_struct(struct)}) + end + + def build_activity(target, activity) do + target + |> Ecto.build_assoc(:activities) + |> changeset(activity) + end + + def put_activity(target, activity) do + put_activity(change(target), target, activity) + end + + def put_activiies(target, activities) do + put_activities(change(target), target, activities) + end + + def put_activity(changeset, target, activity) do + put_activities(changeset, target, [activity]) + end + + def put_activities(%Ecto.Changeset{changes: changes} = changeset, target, activities) do + put_assoc( + changeset, + :activities, + Enum.map(activities, fn activity -> + build_activity(target, put_changes(activity, changes)) + end) + ) + end + + defp put_changes(activity, changes) do + changes = Map.delete(changes, :activities) + Map.put(activity, :changes, changes) + end +end diff --git a/lib/algora/activities/views.ex b/lib/algora/activities/views.ex new file mode 100644 index 000000000..5a83c838b --- /dev/null +++ b/lib/algora/activities/views.ex @@ -0,0 +1,19 @@ +defmodule Algora.Activities.Views do + @moduledoc false + alias Algora.Activities.Activity + + require Algora.Activities.Activity + require EEx + + @base_path Path.join([File.cwd!(), "lib", "algora", "activities", "views"]) + + def render(activity, kind) when kind in ~w(title txt)a do + apply(__MODULE__, :"#{activity.type}_#{kind}", [activity, activity.assoc]) + end + + Enum.each(Activity.types(), fn type -> + base_type = type |> to_string() |> String.split("_") |> List.first() |> String.to_atom() + EEx.function_from_file(:def, :"#{type}_title", Path.join(@base_path, "#{type}.title.eex"), [:activity, base_type]) + EEx.function_from_file(:def, :"#{type}_txt", Path.join(@base_path, "#{type}.txt.eex"), [:activity, base_type]) + end) +end diff --git a/lib/algora/activities/views/bounty_awarded.title.eex b/lib/algora/activities/views/bounty_awarded.title.eex new file mode 100644 index 000000000..fe621f963 --- /dev/null +++ b/lib/algora/activities/views/bounty_awarded.title.eex @@ -0,0 +1 @@ +🎉 <%= bounty.amount %> bounty awarded by <%= bounty.creator.display_name %> diff --git a/lib/algora/activities/views/bounty_awarded.txt.eex b/lib/algora/activities/views/bounty_awarded.txt.eex new file mode 100644 index 000000000..363bfa86a --- /dev/null +++ b/lib/algora/activities/views/bounty_awarded.txt.eex @@ -0,0 +1,3 @@ +Congratulations, you've been awarded a bounty by <%= bounty.creator.display_name %>! + +<%= Algora.Activities.external_url(activity) %> diff --git a/lib/algora/activities/views/bounty_posted.title.eex b/lib/algora/activities/views/bounty_posted.title.eex new file mode 100644 index 000000000..50624f89f --- /dev/null +++ b/lib/algora/activities/views/bounty_posted.title.eex @@ -0,0 +1 @@ +<%= bounty.amount %> bounty posted by <%= bounty.creator.display_name %> diff --git a/lib/algora/activities/views/bounty_posted.txt.eex b/lib/algora/activities/views/bounty_posted.txt.eex new file mode 100644 index 000000000..bdc03098b --- /dev/null +++ b/lib/algora/activities/views/bounty_posted.txt.eex @@ -0,0 +1,3 @@ +A new bounty has been posted by <%= bounty.creator.display_name %> + +<%= Algora.Activities.external_url(activity) %> diff --git a/lib/algora/activities/views/bounty_repriced.title.eex b/lib/algora/activities/views/bounty_repriced.title.eex new file mode 100644 index 000000000..9b6066ce5 --- /dev/null +++ b/lib/algora/activities/views/bounty_repriced.title.eex @@ -0,0 +1 @@ +Reward updated for a bounty posted to Algora diff --git a/lib/algora/activities/views/bounty_repriced.txt.eex b/lib/algora/activities/views/bounty_repriced.txt.eex new file mode 100644 index 000000000..0067ac27a --- /dev/null +++ b/lib/algora/activities/views/bounty_repriced.txt.eex @@ -0,0 +1,3 @@ +A Bounty for <% bounty.ticket.repository.name %> had it's reward updated to <% bounty.amount %> + +<%= Algora.Activities.external_url(activity) %> diff --git a/lib/algora/activities/views/claim_approved.title.eex b/lib/algora/activities/views/claim_approved.title.eex new file mode 100644 index 000000000..ff741adfe --- /dev/null +++ b/lib/algora/activities/views/claim_approved.title.eex @@ -0,0 +1 @@ +A clam has been approved on Algora diff --git a/lib/algora/activities/views/claim_approved.txt.eex b/lib/algora/activities/views/claim_approved.txt.eex new file mode 100644 index 000000000..5af47d319 --- /dev/null +++ b/lib/algora/activities/views/claim_approved.txt.eex @@ -0,0 +1,5 @@ +A claim for the issue "<%= claim.target.title %>" was accepted. + +<%= claim.url %> + +<%= Algora.Activities.external_url(activity) %> diff --git a/lib/algora/activities/views/claim_submitted.title.eex b/lib/algora/activities/views/claim_submitted.title.eex new file mode 100644 index 000000000..8a0e1f057 --- /dev/null +++ b/lib/algora/activities/views/claim_submitted.title.eex @@ -0,0 +1 @@ +A clam has been submitted on Algora diff --git a/lib/algora/activities/views/claim_submitted.txt.eex b/lib/algora/activities/views/claim_submitted.txt.eex new file mode 100644 index 000000000..44a04e882 --- /dev/null +++ b/lib/algora/activities/views/claim_submitted.txt.eex @@ -0,0 +1,5 @@ +A claim for the issue "<%= claim.target.title %>" was submitted. + +<%= claim.url %> + +<%= Algora.Activities.external_url(activity) %> diff --git a/lib/algora/activities/views/contract_created.title.eex b/lib/algora/activities/views/contract_created.title.eex new file mode 100644 index 000000000..d1c4ff5f0 --- /dev/null +++ b/lib/algora/activities/views/contract_created.title.eex @@ -0,0 +1 @@ +A contract has been created on Algora diff --git a/lib/algora/activities/views/contract_created.txt.eex b/lib/algora/activities/views/contract_created.txt.eex new file mode 100644 index 000000000..e1091a060 --- /dev/null +++ b/lib/algora/activities/views/contract_created.txt.eex @@ -0,0 +1,3 @@ +A contract between <%= contract.client.display_name %> and <%= contract.contractor.display_name %> has been created. + +<%= Algora.Activities.external_url(activity) %> diff --git a/lib/algora/activities/views/contract_paid.title.eex b/lib/algora/activities/views/contract_paid.title.eex new file mode 100644 index 000000000..ccfe3d0b8 --- /dev/null +++ b/lib/algora/activities/views/contract_paid.title.eex @@ -0,0 +1 @@ +A contract between has been paid on Algora diff --git a/lib/algora/activities/views/contract_paid.txt.eex b/lib/algora/activities/views/contract_paid.txt.eex new file mode 100644 index 000000000..b16745a3a --- /dev/null +++ b/lib/algora/activities/views/contract_paid.txt.eex @@ -0,0 +1,3 @@ +A contract between "<%= contract.client.display_name %>" and "<%= contract.contractor.display_name %>" has been paid. + +<%= Algora.Activities.external_url(activity) %> diff --git a/lib/algora/activities/views/contract_prepaid.title.eex b/lib/algora/activities/views/contract_prepaid.title.eex new file mode 100644 index 000000000..02a7b2b6d --- /dev/null +++ b/lib/algora/activities/views/contract_prepaid.title.eex @@ -0,0 +1 @@ +A contract between has been prepaid on Algora diff --git a/lib/algora/activities/views/contract_prepaid.txt.eex b/lib/algora/activities/views/contract_prepaid.txt.eex new file mode 100644 index 000000000..ea569b2dc --- /dev/null +++ b/lib/algora/activities/views/contract_prepaid.txt.eex @@ -0,0 +1,3 @@ +A contract for "<%= contract.client.display_name %>" has been prepaid. + +<%= Algora.Activities.external_url(activity) %> diff --git a/lib/algora/activities/views/contract_renewed.title.eex b/lib/algora/activities/views/contract_renewed.title.eex new file mode 100644 index 000000000..75a1561c1 --- /dev/null +++ b/lib/algora/activities/views/contract_renewed.title.eex @@ -0,0 +1 @@ +A contract between has been renewed on Algora diff --git a/lib/algora/activities/views/contract_renewed.txt.eex b/lib/algora/activities/views/contract_renewed.txt.eex new file mode 100644 index 000000000..0dc6a20b5 --- /dev/null +++ b/lib/algora/activities/views/contract_renewed.txt.eex @@ -0,0 +1,3 @@ +A contract between "<%= contract.client.display_name %>" and "<%= contract.contractor.display_name %>" has been renewed. + +<%= Algora.Activities.external_url(activity) %> diff --git a/lib/algora/activities/views/identity_created.title.eex b/lib/algora/activities/views/identity_created.title.eex new file mode 100644 index 000000000..669758af2 --- /dev/null +++ b/lib/algora/activities/views/identity_created.title.eex @@ -0,0 +1 @@ +An identity has been linked on Algora diff --git a/lib/algora/activities/views/identity_created.txt.eex b/lib/algora/activities/views/identity_created.txt.eex new file mode 100644 index 000000000..99358afc1 --- /dev/null +++ b/lib/algora/activities/views/identity_created.txt.eex @@ -0,0 +1,3 @@ +An identity from <%= identity.provider %> has been linked on algora. + +<%= Algora.Activities.external_url(activity) %> diff --git a/lib/algora/activities/views/tip_awarded.title.eex b/lib/algora/activities/views/tip_awarded.title.eex new file mode 100644 index 000000000..c6e1042e5 --- /dev/null +++ b/lib/algora/activities/views/tip_awarded.title.eex @@ -0,0 +1 @@ +You were awarded a tip on Algora diff --git a/lib/algora/activities/views/tip_awarded.txt.eex b/lib/algora/activities/views/tip_awarded.txt.eex new file mode 100644 index 000000000..7190edfc3 --- /dev/null +++ b/lib/algora/activities/views/tip_awarded.txt.eex @@ -0,0 +1,3 @@ +<%= tip.creator.display_name %> sent you a <%= tip.amount %> tip on Algora! + +<%= Algora.Activities.external_url(activity) %> diff --git a/lib/algora/admin/admin.ex b/lib/algora/admin/admin.ex index d2cc07098..84589ddea 100644 --- a/lib/algora/admin/admin.ex +++ b/lib/algora/admin/admin.ex @@ -65,6 +65,13 @@ defmodule Algora.Admin do end end + def make_admin!(user_handle, is_admin) when is_boolean(is_admin) do + user_handle + |> Algora.Accounts.get_user_by_handle() + |> Algora.Accounts.User.is_admin_changeset(is_admin) + |> Algora.Repo.update() + end + defp update_tickets(url, repo_id) do Repo.update_all(from(t in Ticket, where: fragment("?->>'repository_url' = ?", t.provider_meta, ^url)), set: [repository_id: repo_id] diff --git a/lib/algora/analytics/analytics.ex b/lib/algora/analytics/analytics.ex index b8794ba32..1d5f75144 100644 --- a/lib/algora/analytics/analytics.ex +++ b/lib/algora/analytics/analytics.ex @@ -1,28 +1,136 @@ defmodule Algora.Analytics do @moduledoc false - def get_company_analytics(period \\ "30d") do + import Ecto.Query + + alias Algora.Accounts.User + alias Algora.Contracts.Contract + alias Algora.Repo + + require Algora.SQL + + # TODO + # + # active org: org who triggered a GMV event in given period + # GMV events: bounty.created, bounty.rewarded, contract.payment_escrowed, contract.payment_released etc. + # successful contract: currently active or paid contract + # avg time to fill: time from published to accepted, avg over all successful contracts (excl. renewals) + + def get_company_analytics(period \\ "30d", from \\ DateTime.utc_now()) do days = period |> String.replace("d", "") |> String.to_integer() - _since = DateTime.add(DateTime.utc_now(), -days * 24 * 3600) + period_start = DateTime.add(from, -days * 24 * 3600) + previous_period_start = DateTime.add(period_start, -days * 24 * 3600) - # Mock data for demonstration - %{ - total_companies: 150, - companies_change: 12, - companies_trend: :up, - active_companies: 85, - active_change: 5, - active_trend: :up, - avg_time_to_fill: 4.2, - time_to_fill_change: -0.8, - time_to_fill_trend: :down, - contract_success_rate: 92, - success_rate_change: 2, - success_rate_trend: :up, - companies: mock_companies() - } + orgs_query = + from u in User, + where: u.type == :organization, + select: %{ + count_all: count(u.id), + count_current: u.id |> count() |> filter(u.inserted_at <= ^from and u.inserted_at >= ^period_start), + count_previous: + u.id |> count() |> filter(u.inserted_at <= ^period_start and u.inserted_at >= ^previous_period_start), + active_all: u.id |> count() |> filter(u.seeded and u.activated), + active_current: + u.id + |> count() + |> filter(u.seeded and u.activated and u.inserted_at <= ^from and u.inserted_at >= ^period_start), + active_previous: + u.id + |> count() + |> filter( + u.seeded and u.activated and u.inserted_at <= ^period_start and u.inserted_at >= ^previous_period_start + ) + } + + contracts_query = + from u in Contract, + where: u.inserted_at >= ^previous_period_start, + select: %{ + count_current: u.id |> count() |> filter(u.inserted_at < ^from and u.inserted_at >= ^period_start), + count_previous: + u.id |> count() |> filter(u.inserted_at < ^period_start and u.inserted_at >= ^previous_period_start), + success_current: + u.id + |> count() + |> filter( + u.inserted_at < ^from and u.inserted_at >= ^period_start and (u.status == :active or u.status == :paid) + ), + success_previous: + u.id + |> count() + |> filter( + u.inserted_at < ^period_start and u.inserted_at >= ^previous_period_start and + (u.status == :active or u.status == :paid) + ) + } + + companies_query = + from u in User, + where: u.inserted_at >= ^period_start and u.type == :organization, + right_join: c in Contract, + on: c.client_id == u.id, + group_by: u.id, + select: %{ + id: u.id, + name: u.name, + handle: u.handle, + joined_at: u.inserted_at, + total_contracts: c.id |> count() |> filter(c.inserted_at >= ^period_start), + successful_contracts: + c.id |> count() |> filter(c.status == :active or (c.status == :paid and c.inserted_at >= ^period_start)), + last_active_at: u.updated_at, + avatar_url: u.avatar_url + } + + Ecto.Multi.new() + |> Ecto.Multi.one(:orgs, orgs_query) + |> Ecto.Multi.one(:contracts, contracts_query) + |> Ecto.Multi.all(:companies, companies_query) + |> Repo.transaction() + |> case do + {:ok, resp} -> + %{ + orgs: orgs, + contracts: contracts, + companies: companies + } = resp + + current_success_rate = calculate_success_rate(contracts.success_current, contracts.count_current) + previous_success_rate = calculate_success_rate(contracts.success_previous, contracts.count_previous) + + {:ok, + %{ + total_companies: orgs.count_all, + companies_change: orgs.count_current, + companies_trend: calculate_trend(orgs.count_current, orgs.count_previous), + active_companies: orgs.active_all, + active_change: orgs.active_current, + active_trend: calculate_trend(orgs.active_current, orgs.active_previous), + # TODO track time when contract is filled + # + # in open contracts (contracts w/o contractor_id) we track :published_at + # in filled contracts (contracts w/ contractor_id) we track both :published_at (inherited) and :accepted_at + avg_time_to_fill: 0.0, + time_to_fill_change: -0.0, + time_to_fill_trend: :down, + contract_success_rate: current_success_rate, + previous_contract_success_rate: previous_success_rate, + success_rate_change: current_success_rate - previous_success_rate, + success_rate_trend: calculate_trend(current_success_rate, previous_success_rate), + companies: + Enum.map(companies, fn company -> + Map.merge(company, %{ + success_rate: calculate_success_rate(company.successful_contracts, company.total_contracts), + status: if(company.successful_contracts > 0, do: :active, else: :inactive) + }) + end) + }} + + {:error, reason} -> + {:error, reason} + end end - def get_funnel_data(_period \\ "30d") do + def get_funnel_data(_period \\ "30d", _from \\ DateTime.utc_now()) do # Mock funnel data %{ registered: 100, @@ -34,19 +142,10 @@ defmodule Algora.Analytics do } end - defp mock_companies do - [ - %{ - name: "TechCorp", - handle: "techcorp", - avatar_url: "https://example.com/avatar1.jpg", - joined_at: ~U[2024-01-15 00:00:00Z], - status: :active, - total_contracts: 12, - success_rate: 95, - last_active_at: ~U[2024-03-18 14:30:00Z] - } - # Add more mock companies... - ] - end + defp calculate_success_rate(successful, total) when successful == 0 or total == 0, do: 0.0 + defp calculate_success_rate(successful, total), do: Float.ceil(successful / total * 100, 0) + + defp calculate_trend(a, b) when a > b, do: :up + defp calculate_trend(a, b) when a < b, do: :down + defp calculate_trend(a, b) when a == b, do: :same end diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index d8e940aba..8e820281d 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -49,7 +49,12 @@ defmodule Algora.Bounties do creator_id: creator.id }) - case Repo.insert(changeset) do + changeset + |> Repo.insert_with_activity(%{ + type: :bounty_posted, + notify_users: [creator.id] + }) + |> case do {:ok, bounty} -> {:ok, bounty} @@ -141,7 +146,9 @@ defmodule Algora.Bounties do url: source.url }) - case Repo.insert(changeset) do + activity_attrs = %{type: :claim_submitted, notify_users: [user.id]} + + case Repo.insert_with_activity(changeset, activity_attrs) do {:ok, claim} -> {:ok, claim} @@ -253,8 +260,14 @@ defmodule Algora.Bounties do recipient_id: recipient.id }) + activity_attrs = + %{ + type: :tip_awarded, + notify_users: [recipient.id] + } + Repo.transact(fn -> - with {:ok, tip} <- Repo.insert(changeset) do + with {:ok, tip} <- Repo.insert_with_activity(changeset, activity_attrs) do create_payment_session( %{owner: owner, amount: amount, description: "Tip payment for OSS contributions"}, ticket_ref: opts[:ticket_ref], @@ -276,12 +289,18 @@ defmodule Algora.Bounties do ) :: {:ok, String.t()} | {:error, atom()} def reward_bounty(%{owner: owner, amount: amount, bounty_id: bounty_id, claims: claims}, opts \\ []) do - create_payment_session( - %{owner: owner, amount: amount, description: "Bounty payment for OSS contributions"}, - ticket_ref: opts[:ticket_ref], - bounty_id: bounty_id, - claims: claims - ) + Repo.transact(fn -> + activity_attrs = %{type: :bounty_awarded} + + with {:ok, _activity} <- Algora.Activities.insert(%Bounty{id: bounty_id}, activity_attrs) do + create_payment_session( + %{owner: owner, amount: amount, description: "Bounty payment for OSS contributions"}, + ticket_ref: opts[:ticket_ref], + bounty_id: bounty_id, + claims: claims + ) + end + end) end @spec generate_line_items( @@ -296,7 +315,7 @@ defmodule Algora.Bounties do def generate_line_items(%{amount: amount}, opts \\ []) do ticket_ref = opts[:ticket_ref] recipient = opts[:recipient] - claims = opts[:claims] + claims = opts[:claims] || [] description = if(ticket_ref, do: "#{ticket_ref[:owner]}/#{ticket_ref[:repo]}##{ticket_ref[:number]}") @@ -382,6 +401,7 @@ defmodule Algora.Bounties do claims: opts[:claims] || [], tip_id: opts[:tip_id], bounty_id: opts[:bounty_id], + claim_id: nil, amount: amount, creator_id: owner.id, group_id: tx_group_id @@ -667,9 +687,9 @@ defmodule Algora.Bounties do id: credit_id, tip_id: params.tip_id, bounty_id: params.bounty_id, - claim_id: params.claim_id, + claim_id: params[:claim_id], amount: params.amount, - user_id: params.recipient_id, + user_id: params[:recipient_id], linked_transaction_id: debit_id, group_id: params.group_id }) do diff --git a/lib/algora/bounties/schemas/attempt.ex b/lib/algora/bounties/schemas/attempt.ex index 88b4256f7..d7d50e9ef 100644 --- a/lib/algora/bounties/schemas/attempt.ex +++ b/lib/algora/bounties/schemas/attempt.ex @@ -2,10 +2,14 @@ defmodule Algora.Bounties.Attempt do @moduledoc false use Algora.Schema + alias Algora.Activities.Activity + typed_schema "attempts" do belongs_to :bounty, Algora.Bounties.Bounty belongs_to :user, Algora.Accounts.User + has_many :activities, {"attempt_activities", Activity}, foreign_key: :assoc_id + timestamps() end diff --git a/lib/algora/bounties/schemas/bonus.ex b/lib/algora/bounties/schemas/bonus.ex index 33b52c551..475712366 100644 --- a/lib/algora/bounties/schemas/bonus.ex +++ b/lib/algora/bounties/schemas/bonus.ex @@ -2,10 +2,14 @@ defmodule Algora.Bounties.Bonus do @moduledoc false use Algora.Schema + alias Algora.Activities.Activity + typed_schema "bonuses" do belongs_to :bounty, Algora.Bounties.Bounty belongs_to :user, Algora.Accounts.User + has_many :activities, {"bonus_activities", Activity}, foreign_key: :assoc_id + timestamps() end diff --git a/lib/algora/bounties/schemas/bounty.ex b/lib/algora/bounties/schemas/bounty.ex index 9cf85c1bd..21b3c9163 100644 --- a/lib/algora/bounties/schemas/bounty.ex +++ b/lib/algora/bounties/schemas/bounty.ex @@ -15,10 +15,17 @@ defmodule Algora.Bounties.Bounty do belongs_to :creator, User has_many :attempts, Algora.Bounties.Attempt has_many :transactions, Algora.Payments.Transaction + has_many :activities, {"bounty_activities", Algora.Activities.Activity}, foreign_key: :assoc_id timestamps() end + def preload(id) do + from a in __MODULE__, + preload: [:ticket, :owner, :creator], + where: a.id == ^id + end + def changeset(bounty, attrs) do bounty |> cast(attrs, [:amount, :ticket_id, :owner_id, :creator_id]) diff --git a/lib/algora/bounties/schemas/claim.ex b/lib/algora/bounties/schemas/claim.ex index 7043ad0e8..2031a3da8 100644 --- a/lib/algora/bounties/schemas/claim.ex +++ b/lib/algora/bounties/schemas/claim.ex @@ -2,6 +2,7 @@ defmodule Algora.Bounties.Claim do @moduledoc false use Algora.Schema + alias Algora.Activities.Activity alias Algora.Bounties.Claim alias Algora.Workspace.Ticket @@ -17,6 +18,7 @@ defmodule Algora.Bounties.Claim do belongs_to :user, Algora.Accounts.User, null: false has_many :transactions, Algora.Payments.Transaction + has_many :activities, {"claim_activities", Activity}, foreign_key: :assoc_id timestamps() end @@ -32,6 +34,12 @@ defmodule Algora.Bounties.Claim do |> unique_constraint([:target_id, :user_id]) end + def preload(id) do + from a in __MODULE__, + preload: [:source, :target, :user], + where: a.id == ^id + end + def put_group_id(changeset) do case get_field(changeset, :group_id) do nil -> put_change(changeset, :group_id, get_field(changeset, :id)) diff --git a/lib/algora/bounties/schemas/tip.ex b/lib/algora/bounties/schemas/tip.ex index b9817c98e..eeba86264 100644 --- a/lib/algora/bounties/schemas/tip.ex +++ b/lib/algora/bounties/schemas/tip.ex @@ -3,6 +3,7 @@ defmodule Algora.Bounties.Tip do use Algora.Schema alias Algora.Accounts.User + alias Algora.Activities.Activity typed_schema "tips" do field :amount, Algora.Types.Money @@ -14,6 +15,8 @@ defmodule Algora.Bounties.Tip do belongs_to :recipient, User has_many :transactions, Algora.Payments.Transaction + has_many :activities, {"tip_activities", Activity}, foreign_key: :assoc_id + timestamps() end @@ -28,4 +31,10 @@ defmodule Algora.Bounties.Tip do |> foreign_key_constraint(:recipient) |> Algora.Validations.validate_money_positive(:amount) end + + def preload(id) do + from a in __MODULE__, + preload: [:ticket, :owner, :creator, :recipient], + where: a.id == ^id + end end diff --git a/lib/algora/chat/schemas/message.ex b/lib/algora/chat/schemas/message.ex index 30a32dc37..be3c1bde6 100644 --- a/lib/algora/chat/schemas/message.ex +++ b/lib/algora/chat/schemas/message.ex @@ -2,12 +2,16 @@ defmodule Algora.Chat.Message do @moduledoc false use Algora.Schema + alias Algora.Activities.Activity + typed_schema "messages" do field :content, :string belongs_to :thread, Algora.Chat.Thread belongs_to :sender, Algora.Accounts.User + has_many :activities, {"message_activities", Activity}, foreign_key: :assoc_id + timestamps() end diff --git a/lib/algora/chat/schemas/thread.ex b/lib/algora/chat/schemas/thread.ex index d9dfe97d7..98475fcad 100644 --- a/lib/algora/chat/schemas/thread.ex +++ b/lib/algora/chat/schemas/thread.ex @@ -2,11 +2,14 @@ defmodule Algora.Chat.Thread do @moduledoc false use Algora.Schema + alias Algora.Activities.Activity + typed_schema "threads" do field :title, :string has_many :messages, Algora.Chat.Message has_many :participants, Algora.Chat.Participant + has_many :activities, {"thread_activities", Activity}, foreign_key: :assoc_id timestamps() end diff --git a/lib/algora/contracts/contracts.ex b/lib/algora/contracts/contracts.ex index 4db664a31..e90cf3006 100644 --- a/lib/algora/contracts/contracts.ex +++ b/lib/algora/contracts/contracts.ex @@ -3,6 +3,7 @@ defmodule Algora.Contracts do import Ecto.Changeset import Ecto.Query + alias Algora.Activities alias Algora.Contracts.Contract alias Algora.Contracts.Timesheet alias Algora.FeeTier @@ -377,7 +378,16 @@ defmodule Algora.Contracts do with {:ok, txs} <- initialize_prepayment_transaction(contract), {:ok, invoice} <- maybe_generate_invoice(contract, txs.charge), {:ok, _invoice} <- maybe_pay_invoice(contract, invoice, txs) do + Activities.insert(contract, %{type: :contract_prepaid}) + {:ok, txs} + else + error -> + Activities.insert(contract, %{ + type: :contract_prepayment_failed + }) + + error end end @@ -483,6 +493,7 @@ defmodule Algora.Contracts do {:error, error} -> update_transaction_status(transaction, {:error, error}) + Activities.insert(contract, %{type: :contract_prepayment_failed}) {:error, error} end end @@ -510,7 +521,10 @@ defmodule Algora.Contracts do defp mark_contract_as_paid(contract) do contract |> change(%{status: :paid}) - |> Repo.update() + |> Repo.update_with_activity(%{ + type: :contract_paid, + notify_users: [contract.client_id, contract.contractor_id] + }) end defp renew_contract(contract) do @@ -527,7 +541,10 @@ defmodule Algora.Contracts do hourly_rate: contract.hourly_rate, hours_per_week: contract.hours_per_week }) - |> Repo.insert() + |> Repo.insert_with_activity(%{ + type: :contract_renewed, + notify_users: [contract.client_id, contract.contractor_id] + }) end def calculate_transfer_amount(contract) do @@ -601,6 +618,7 @@ defmodule Algora.Contracts do on: tt.original_contract_id == c.original_contract_id, as: :tt ) + |> join(:left, [c], act in assoc(c, :activities), as: :act) |> select_merge([ta: ta, tt: tt], %{ amount_credited: Algora.SQL.money_or_zero(ta.amount_credited), amount_debited: Algora.SQL.money_or_zero(ta.amount_debited), @@ -611,11 +629,12 @@ defmodule Algora.Contracts do total_transferred: Algora.SQL.money_or_zero(tt.total_transferred), total_withdrawn: Algora.SQL.money_or_zero(tt.total_withdrawn) }) - |> preload([ts: ts, txs: txs, cl: cl, ct: ct, cu: cu, dpm: dpm], + |> preload([ts: ts, txs: txs, cl: cl, ct: ct, cu: cu, dpm: dpm, act: act], timesheet: ts, transactions: txs, client: {cl, customer: {cu, default_payment_method: dpm}}, - contractor: ct + contractor: ct, + activities: act ) |> Repo.all() |> Enum.map(&Contract.after_load/1) diff --git a/lib/algora/contracts/schemas/contract.ex b/lib/algora/contracts/schemas/contract.ex index 1cce54452..5b12774f2 100644 --- a/lib/algora/contracts/schemas/contract.ex +++ b/lib/algora/contracts/schemas/contract.ex @@ -3,6 +3,7 @@ defmodule Algora.Contracts.Contract do use Algora.Schema alias Algora.Accounts.User + alias Algora.Activities.Activity alias Algora.Contracts.Contract alias Algora.MoneyUtils @@ -36,6 +37,8 @@ defmodule Algora.Contracts.Contract do has_many :reviews, Algora.Reviews.Review has_one :timesheet, Algora.Contracts.Timesheet + has_many :activities, {"contract_activities", Activity}, foreign_key: :assoc_id + timestamps() end diff --git a/lib/algora/contracts/schemas/timesheet.ex b/lib/algora/contracts/schemas/timesheet.ex index 8a1f682ef..df1894185 100644 --- a/lib/algora/contracts/schemas/timesheet.ex +++ b/lib/algora/contracts/schemas/timesheet.ex @@ -2,6 +2,8 @@ defmodule Algora.Contracts.Timesheet do @moduledoc false use Algora.Schema + alias Algora.Activities.Activity + typed_schema "timesheets" do field :hours_worked, :integer field :description, :string @@ -9,6 +11,8 @@ defmodule Algora.Contracts.Timesheet do belongs_to :contract, Algora.Contracts.Contract has_many :transactions, Algora.Payments.Transaction + has_many :activities, {"timesheet_activities", Activity}, foreign_key: :assoc_id + timestamps() end diff --git a/lib/algora/integrations/stripe/behaviour.ex b/lib/algora/integrations/stripe/behaviour.ex index 2c7e58a6d..a12684d27 100644 --- a/lib/algora/integrations/stripe/behaviour.ex +++ b/lib/algora/integrations/stripe/behaviour.ex @@ -4,4 +4,5 @@ defmodule Algora.Stripe.Behaviour do @callback create_invoice_item(map()) :: {:ok, map()} | {:error, any()} @callback pay_invoice(String.t(), map()) :: {:ok, map()} | {:error, any()} @callback create_transfer(map()) :: {:ok, map()} | {:error, any()} + @callback create_session(map()) :: {:ok, map()} | {:error, any()} end diff --git a/lib/algora/integrations/stripe/impl.ex b/lib/algora/integrations/stripe/impl.ex index c9bb8b4b4..402cbe728 100644 --- a/lib/algora/integrations/stripe/impl.ex +++ b/lib/algora/integrations/stripe/impl.ex @@ -22,4 +22,9 @@ defmodule Algora.Stripe.Impl do def create_transfer(params) do Stripe.Transfer.create(params) end + + @impl true + def create_session(params) do + Stripe.Session.create(params) + end end diff --git a/lib/algora/integrations/stripe/stripe.ex b/lib/algora/integrations/stripe/stripe.ex index 1f9baf507..60469e12b 100644 --- a/lib/algora/integrations/stripe/stripe.ex +++ b/lib/algora/integrations/stripe/stripe.ex @@ -23,6 +23,11 @@ defmodule Algora.Stripe do impl().create_transfer(params) end + @impl true + def create_session(params) do + impl().create_session(params) + end + def field_to_id(nil), do: nil def field_to_id(field) when is_binary(field), do: field def field_to_id(field), do: field.id diff --git a/lib/algora/jobs/schemas/application.ex b/lib/algora/jobs/schemas/application.ex index 252451c2e..6f24f3cb9 100644 --- a/lib/algora/jobs/schemas/application.ex +++ b/lib/algora/jobs/schemas/application.ex @@ -2,10 +2,14 @@ defmodule Algora.Jobs.Application do @moduledoc false use Algora.Schema + alias Algora.Activities.Activity + typed_schema "applications" do belongs_to :job, Algora.Jobs.Job belongs_to :user, Algora.Accounts.User + has_many :activities, {"application_activities", Activity}, foreign_key: :assoc_id + timestamps() end diff --git a/lib/algora/jobs/schemas/job.ex b/lib/algora/jobs/schemas/job.ex index 09b140cab..6aeaa7cb0 100644 --- a/lib/algora/jobs/schemas/job.ex +++ b/lib/algora/jobs/schemas/job.ex @@ -2,9 +2,13 @@ defmodule Algora.Jobs.Job do @moduledoc false use Algora.Schema + alias Algora.Activities.Activity + typed_schema "jobs" do belongs_to :user, Algora.Accounts.User + has_many :activities, {"job_activities", Activity}, foreign_key: :assoc_id + timestamps() end diff --git a/lib/algora/notifier.ex b/lib/algora/notifier.ex new file mode 100644 index 000000000..dd625b41f --- /dev/null +++ b/lib/algora/notifier.ex @@ -0,0 +1,14 @@ +defmodule Algora.Notifier do + @moduledoc false + def notify_welcome_org(_user, _org) do + :ok + end + + def notify_welcome_developer(_user) do + :ok + end + + def notify_stripe_account_link_error(_user, _error) do + :ok + end +end diff --git a/lib/algora/payments/errors.ex b/lib/algora/payments/errors.ex new file mode 100644 index 000000000..88fa98372 --- /dev/null +++ b/lib/algora/payments/errors.ex @@ -0,0 +1,14 @@ +defmodule Algora.Payments.StripeAccountLinkError do + @moduledoc false + defexception [:message] +end + +defmodule Algora.Payments.StripeAccountCreateError do + @moduledoc false + defexception [:message] +end + +defmodule Algora.Payments.StripeAccountDeleteError do + @moduledoc false + defexception [:message] +end diff --git a/lib/algora/payments/payments.ex b/lib/algora/payments/payments.ex index e5251900d..96d167c75 100644 --- a/lib/algora/payments/payments.ex +++ b/lib/algora/payments/payments.ex @@ -28,7 +28,7 @@ defmodule Algora.Payments do ) :: {:ok, Stripe.Session.t()} | {:error, Stripe.Error.t()} def create_stripe_session(line_items, payment_intent_data) do - Stripe.Session.create(%{ + Algora.Stripe.create_session(%{ mode: "payment", billing_address_collection: "required", line_items: line_items, diff --git a/lib/algora/payments/schemas/account.ex b/lib/algora/payments/schemas/account.ex index e38c1d041..ec6de5847 100644 --- a/lib/algora/payments/schemas/account.ex +++ b/lib/algora/payments/schemas/account.ex @@ -2,6 +2,7 @@ defmodule Algora.Payments.Account do @moduledoc false use Algora.Schema + alias Algora.Activities.Activity alias Algora.Stripe @derive {Inspect, except: [:provider_meta]} @@ -24,6 +25,7 @@ defmodule Algora.Payments.Account do belongs_to :user, Algora.Accounts.User, null: false + has_many :activities, {"account_activities", Activity}, foreign_key: :assoc_id timestamps() end diff --git a/lib/algora/payments/schemas/customer.ex b/lib/algora/payments/schemas/customer.ex index c84bf8d2b..1400b3f7e 100644 --- a/lib/algora/payments/schemas/customer.ex +++ b/lib/algora/payments/schemas/customer.ex @@ -2,6 +2,8 @@ defmodule Algora.Payments.Customer do @moduledoc false use Algora.Schema + alias Algora.Activities.Activity + @derive {Inspect, except: [:provider_meta]} typed_schema "customers" do field :provider, :string @@ -16,6 +18,8 @@ defmodule Algora.Payments.Customer do foreign_key: :customer_id, where: [is_default: true] + has_many :activities, {"customer_activities", Activity}, foreign_key: :assoc_id + timestamps() end diff --git a/lib/algora/payments/schemas/payment_method.ex b/lib/algora/payments/schemas/payment_method.ex index b89302732..fc5eb32a1 100644 --- a/lib/algora/payments/schemas/payment_method.ex +++ b/lib/algora/payments/schemas/payment_method.ex @@ -2,6 +2,8 @@ defmodule Algora.Payments.PaymentMethod do @moduledoc false use Algora.Schema + alias Algora.Activities.Activity + typed_schema "payment_methods" do field :provider, :string field :provider_id, :string @@ -11,6 +13,8 @@ defmodule Algora.Payments.PaymentMethod do belongs_to :customer, Algora.Payments.Customer + has_many :activities, {"platform_transaction_activities", Activity}, foreign_key: :assoc_id + timestamps() end diff --git a/lib/algora/payments/schemas/platform_transaction.ex b/lib/algora/payments/schemas/platform_transaction.ex index bacb3a3e2..0edda0f8b 100644 --- a/lib/algora/payments/schemas/platform_transaction.ex +++ b/lib/algora/payments/schemas/platform_transaction.ex @@ -2,6 +2,8 @@ defmodule Algora.Payments.PlatformTransaction do @moduledoc false use Algora.Schema + alias Algora.Activities.Activity + @derive {Inspect, except: [:provider_meta]} typed_schema "platform_transactions" do field :provider, :string @@ -13,6 +15,7 @@ defmodule Algora.Payments.PlatformTransaction do field :type, :string field :reporting_category, :string + has_many :activities, {"platform_transaction_activities", Activity}, foreign_key: :assoc_id timestamps() end diff --git a/lib/algora/payments/schemas/transaction.ex b/lib/algora/payments/schemas/transaction.ex index 4c0bb1c4e..78effa04b 100644 --- a/lib/algora/payments/schemas/transaction.ex +++ b/lib/algora/payments/schemas/transaction.ex @@ -2,6 +2,7 @@ defmodule Algora.Payments.Transaction do @moduledoc false use Algora.Schema + alias Algora.Activities.Activity alias Algora.Contracts.Contract alias Algora.Types.Money @@ -40,6 +41,8 @@ defmodule Algora.Payments.Transaction do belongs_to :tip, Algora.Bounties.Tip belongs_to :linked_transaction, Algora.Payments.Transaction + has_many :activities, {"transaction_activities", Activity}, foreign_key: :assoc_id + timestamps() end diff --git a/lib/algora/projects/schemas/project.ex b/lib/algora/projects/schemas/project.ex index 8aa28ad48..5907437e6 100644 --- a/lib/algora/projects/schemas/project.ex +++ b/lib/algora/projects/schemas/project.ex @@ -2,6 +2,8 @@ defmodule Algora.Projects.Project do @moduledoc false use Algora.Schema + alias Algora.Activities.Activity + typed_schema "projects" do field :name, :string @@ -9,6 +11,7 @@ defmodule Algora.Projects.Project do # has_many :milestones, Algora.Projects.Milestone # has_many :assignees, Algora.Projects.Assignee # has_many :transactions, Algora.Payments.Transaction + has_many :activities, {"project_activities", Activity}, foreign_key: :assoc_id timestamps() end diff --git a/lib/algora/repo.ex b/lib/algora/repo.ex index 5a788546f..070ad8b48 100644 --- a/lib/algora/repo.ex +++ b/lib/algora/repo.ex @@ -68,4 +68,55 @@ defmodule Algora.Repo do opts ) end + + @spec insert_with_activity(Ecto.Changeset.t(), map()) :: + {:ok, struct()} | {:error, Ecto.Changeset.t()} + def insert_with_activity(changeset, activity) do + Ecto.Multi.new() + |> Ecto.Multi.insert(:target, changeset) + |> with_activity(activity) + |> transaction() + |> extract_target() + end + + @spec update_with_activity(Ecto.Changeset.t(), map()) :: + {:ok, struct()} | {:error, Ecto.Changeset.t()} + def update_with_activity(changeset, activity) do + Ecto.Multi.new() + |> Ecto.Multi.update(:target, changeset) + |> with_activity(activity) + |> transaction() + |> extract_target() + end + + @spec delete_with_activity(Ecto.Changeset.t(), map()) :: + {:ok, struct()} | {:error, Ecto.Changeset.t()} + def delete_with_activity(changeset, activity) do + Ecto.Multi.new() + |> Ecto.Multi.delete(:target, changeset) + |> with_activity(activity) + |> transaction() + |> extract_target() + end + + @spec with_activity(Ecto.Multi.t(), map()) :: Ecto.Multi.t() + def with_activity(multi, activity) do + multi + |> Ecto.Multi.insert(:activity, fn %{target: target} -> + Algora.Activities.Activity.build_activity(target, Map.put(activity, :id, target.id)) + end) + |> Oban.insert(:notification, fn %{activity: activity, target: target} -> + Algora.Activities.Notifier.changeset(activity, target) + end) + end + + defp extract_target(response) do + case response do + {:ok, %{target: target}} -> + {:ok, target} + + {:error, :target, target, _extra} -> + {:error, target} + end + end end diff --git a/lib/algora/reviews/schemas/review.ex b/lib/algora/reviews/schemas/review.ex index c39175237..557cd7fc2 100644 --- a/lib/algora/reviews/schemas/review.ex +++ b/lib/algora/reviews/schemas/review.ex @@ -3,6 +3,7 @@ defmodule Algora.Reviews.Review do use Algora.Schema alias Algora.Accounts.User + alias Algora.Activities.Activity typed_schema "reviews" do field :rating, :integer @@ -15,17 +16,20 @@ defmodule Algora.Reviews.Review do belongs_to :reviewer, User belongs_to :reviewee, User + has_many :activities, {"review_activities", Activity}, foreign_key: :assoc_id + timestamps() end def changeset(review, attrs) do review - |> cast(attrs, [:rating, :content, :visibility, :contract_id, :reviewer_id, :reviewee_id]) - |> validate_required([:rating, :content, :contract_id, :reviewer_id, :reviewee_id]) + |> cast(attrs, [:rating, :content, :visibility, :contract_id, :reviewer_id, :reviewee_id, :organization_id]) + |> validate_required([:rating, :content, :contract_id, :reviewer_id, :reviewee_id, :organization_id]) |> validate_number(:rating, greater_than_or_equal_to: min_rating(), less_than_or_equal_to: max_rating() ) + |> generate_id() end def min_rating, do: 1 diff --git a/lib/algora/signal.ex b/lib/algora/signal.ex new file mode 100644 index 000000000..12003b84f --- /dev/null +++ b/lib/algora/signal.ex @@ -0,0 +1,27 @@ +defmodule Algora.Signal do + @moduledoc false + @provider Appsignal + + def send_error(%Ecto.Changeset{} = error, exception) do + send_error(error, exception, []) + end + + def send_error(%Stripe.Error{} = error, exception) do + send_error(error, exception, []) + end + + def send_error(%Stripe.Error{} = error, exception, stacktrace) do + case error do + %{extra: %{raw_error: %{"message" => message}}} -> + send_error(%{exception | message: message}, stacktrace) + + _error -> + send_error(%{exception | message: "Unknown Stripe error"}, stacktrace) + end + + :ok + end + + defdelegate send_error(exception, stacktrace), to: @provider + defdelegate send_error(kind, reason, stacktrace), to: @provider +end diff --git a/lib/algora/workspace/schemas/installation.ex b/lib/algora/workspace/schemas/installation.ex index f2db6ac4a..33878269b 100644 --- a/lib/algora/workspace/schemas/installation.ex +++ b/lib/algora/workspace/schemas/installation.ex @@ -3,6 +3,7 @@ defmodule Algora.Workspace.Installation do use Algora.Schema alias Algora.Accounts.User + alias Algora.Activities.Activity @derive {Inspect, except: [:provider_meta]} typed_schema "installations" do @@ -17,6 +18,7 @@ defmodule Algora.Workspace.Installation do belongs_to :owner, User, null: false belongs_to :connected_user, User, null: false + has_many :activities, {"installation_activities", Activity}, foreign_key: :assoc_id timestamps() end diff --git a/lib/algora/workspace/schemas/repository.ex b/lib/algora/workspace/schemas/repository.ex index cbdeaff5f..0cec87d3f 100644 --- a/lib/algora/workspace/schemas/repository.ex +++ b/lib/algora/workspace/schemas/repository.ex @@ -2,6 +2,7 @@ defmodule Algora.Workspace.Repository do @moduledoc false use Algora.Schema + alias Algora.Activities.Activity alias Algora.Workspace.Repository @derive {Inspect, except: [:provider_meta]} @@ -17,6 +18,7 @@ defmodule Algora.Workspace.Repository do field :og_image_updated_at, :utc_datetime_usec has_many :tickets, Algora.Workspace.Ticket + has_many :activities, {"repository_activities", Activity}, foreign_key: :assoc_id belongs_to :user, Algora.Accounts.User, null: false timestamps() diff --git a/lib/algora/workspace/schemas/ticket.ex b/lib/algora/workspace/schemas/ticket.ex index 851a53222..27bbe6ddd 100644 --- a/lib/algora/workspace/schemas/ticket.ex +++ b/lib/algora/workspace/schemas/ticket.ex @@ -2,6 +2,7 @@ defmodule Algora.Workspace.Ticket do @moduledoc false use Algora.Schema + alias Algora.Activities.Activity alias Algora.Workspace.Ticket @derive {Inspect, except: [:provider_meta]} @@ -19,6 +20,8 @@ defmodule Algora.Workspace.Ticket do belongs_to :repository, Algora.Workspace.Repository has_many :bounties, Algora.Bounties.Bounty + has_many :activities, {"ticket_activities", Activity}, foreign_key: :assoc_id + timestamps() end diff --git a/lib/algora_web/components/activity.ex b/lib/algora_web/components/activity.ex new file mode 100644 index 000000000..a0e57bf83 --- /dev/null +++ b/lib/algora_web/components/activity.ex @@ -0,0 +1,121 @@ +defmodule AlgoraWeb.Components.Activity do + @moduledoc false + use AlgoraWeb.Component + + import AlgoraWeb.CoreComponents + + alias Algora.Activities + + attr :activities, :list, required: true + attr :id, :string, required: true + + def activities_timeline(assigns) do + ~H""" +
+
+
+ <.activity_card activity={activity} /> +
+
+
+ """ + end + + attr :activity, :map, required: true + + def activity_card(assigns) do + ~H""" + <.link + href={Activities.redirect_url_for_activity(assigns[:activity])} + class="flex flex-grow items-center gap-4 mt-4 mb-4 p-4 pb-4 border-b w-full last:border-none first:border-t first:mt-0 first:pt-4" + tabindex="-1" + phx-mounted={ + JS.transition( + {"first:ease-in duration-500", "first:opacity-0 first:p-0 first:h-0", "first:opacity-100"}, + time: 500 + ) + } + > +
+ <.icon name={activity_icon(to_string(@activity.type))} class="h-5 w-5" /> +
+
+
+ <.activity_name type={assigns.activity.type} /> +
+
+ {Calendar.strftime(assigns.activity.inserted_at, "%b %d, %Y, %H:%M:%S")} +
+
+ + """ + end + + attr :type, :atom, required: true + + def activity_name(%{type: type} = assigns) do + assigns = assign(assigns, :name, Activities.activity_type_to_name(type)) + + ~H""" +
{@name}
+ """ + end + + attr :id, :string, required: true + attr :class, :string, default: nil + attr :activities, :list, required: true + + def dropdown_activities(assigns) do + ~H""" +
+
+ +
+ +
+ """ + end + + defp activity_icon(_type) do + "tabler-file-check" + end + + defp activity_background_class(_type) do + "bg-primary/20" + end +end diff --git a/lib/algora_web/components/layouts/org.html.heex b/lib/algora_web/components/layouts/org.html.heex index 557a1f4fb..4ccecc0f7 100644 --- a/lib/algora_web/components/layouts/org.html.heex +++ b/lib/algora_web/components/layouts/org.html.heex @@ -116,6 +116,12 @@ Docs <%= if @current_user do %> + {live_render(@socket, AlgoraWeb.Activity.UserNavTimelineLive, + id: "activity-timeline", + session: %{}, + sticky: true, + assigns: %{current_user: @current_user} + )}
<.dropdown2 id="dashboard-dropdown"> <:img src={@current_org.avatar_url} alt={@current_org.handle} /> diff --git a/lib/algora_web/components/layouts/user.html.heex b/lib/algora_web/components/layouts/user.html.heex index ff6723ed6..ec077fda5 100644 --- a/lib/algora_web/components/layouts/user.html.heex +++ b/lib/algora_web/components/layouts/user.html.heex @@ -170,6 +170,14 @@ Docs <%= if @current_user do %> +
+ {live_render(@socket, AlgoraWeb.Activity.UserNavTimelineLive, + id: "activity-timeline", + session: %{}, + sticky: true, + assigns: %{current_user: @current_user} + )} +
<.dropdown2 id="dashboard-dropdown"> <:img src={@current_user.avatar_url} alt={@current_user.handle} /> diff --git a/lib/algora_web/components/ui/drawer.ex b/lib/algora_web/components/ui/drawer.ex index 204608ade..71a665948 100644 --- a/lib/algora_web/components/ui/drawer.ex +++ b/lib/algora_web/components/ui/drawer.ex @@ -32,7 +32,7 @@ defmodule AlgoraWeb.Components.UI.Drawer do
"inset-x-0 bottom-0 rounded-t-xl" "right" -> "inset-y-0 right-0 h-full max-w-lg w-full" @@ -60,7 +60,7 @@ defmodule AlgoraWeb.Components.UI.Drawer do diff --git a/lib/algora_web/controllers/activity_controller.ex b/lib/algora_web/controllers/activity_controller.ex new file mode 100644 index 000000000..c7e8369a9 --- /dev/null +++ b/lib/algora_web/controllers/activity_controller.ex @@ -0,0 +1,11 @@ +defmodule AlgoraWeb.ActivityController do + use AlgoraWeb, :controller + + alias Algora.Activities + + def get(conn, %{"table_prefix" => table, "activity_id" => id} = _params) do + with {:ok, url} <- Activities.assoc_url("#{table}_activities", id) do + redirect(conn, external: url) + end + end +end diff --git a/lib/algora_web/live/activity/user_nav_timeline.ex b/lib/algora_web/live/activity/user_nav_timeline.ex new file mode 100644 index 000000000..22300688b --- /dev/null +++ b/lib/algora_web/live/activity/user_nav_timeline.ex @@ -0,0 +1,31 @@ +defmodule AlgoraWeb.Activity.UserNavTimelineLive do + @moduledoc false + use AlgoraWeb, :live_view + + import AlgoraWeb.Components.Activity + + alias Algora.Activities + + def mount(_params, %{"user_id" => user_id}, socket) when is_binary(user_id) do + :ok = Activities.subscribe_user(user_id) + + {:ok, + socket + |> stream(:activities, []) + |> start_async(:get_activities, fn -> Activities.all_for_user(user_id) end)} + end + + def handle_async(:get_activities, {:ok, fetched}, socket) do + {:noreply, stream(socket, :activities, fetched)} + end + + def handle_info(%Activities.Activity{} = activity, socket) do + {:noreply, stream_insert(socket, :activities, activity, at: 0)} + end + + def render(assigns) do + ~H""" + <.dropdown_activities activities={@streams.activities} id="activities-dropdown" /> + """ + end +end diff --git a/lib/algora_web/live/admin/company_analytics_live.ex b/lib/algora_web/live/admin/company_analytics_live.ex index 2e5a0a963..d2c6605bc 100644 --- a/lib/algora_web/live/admin/company_analytics_live.ex +++ b/lib/algora_web/live/admin/company_analytics_live.ex @@ -2,17 +2,23 @@ defmodule AlgoraWeb.Admin.CompanyAnalyticsLive do @moduledoc false use AlgoraWeb, :live_view + import AlgoraWeb.Components.Activity + + alias Algora.Activities alias Algora.Analytics def mount(_params, _session, socket) do - analytics = Analytics.get_company_analytics() + {:ok, analytics} = Analytics.get_company_analytics() funnel_data = Analytics.get_funnel_data() + :ok = Activities.subscribe() {:ok, socket |> assign(:analytics, analytics) |> assign(:funnel_data, funnel_data) - |> assign(:selected_period, "30d")} + |> assign(:selected_period, "30d") + |> stream(:activities, []) + |> start_async(:get_activities, fn -> Activities.all() end)} end def render(assigns) do @@ -31,17 +37,29 @@ defmodule AlgoraWeb.Admin.CompanyAnalyticsLive do
- - <.card> - <.card_header> - <.card_title>Company Funnel - - <.card_content> -
- -
- - + +
+
+ <.card> + <.card_header> + <.card_title>Company Funnel + + <.card_content> +
+ +
+ + +
+ <.scroll_area class="w-1/4 ml-4 pr-4"> + <.card class="h-[500px]"> + <.card_header> + <.card_title>Recent Activities + + <.activities_timeline id="admin-activities-timeline" activities={@streams.activities} /> + + +
<.stat_card @@ -134,7 +152,7 @@ defmodule AlgoraWeb.Admin.CompanyAnalyticsLive do def status_color(_), do: "secondary" def handle_event("select_period", %{"period" => period}, socket) do - analytics = Analytics.get_company_analytics(period) + {:ok, analytics} = Analytics.get_company_analytics(period) funnel_data = Analytics.get_funnel_data(period) {:noreply, @@ -143,4 +161,12 @@ defmodule AlgoraWeb.Admin.CompanyAnalyticsLive do |> assign(:funnel_data, funnel_data) |> assign(:selected_period, period)} end + + def handle_async(:get_activities, {:ok, fetched}, socket) do + {:noreply, stream(socket, :activities, fetched)} + end + + def handle_info(%Activities.Activity{} = activity, socket) do + {:noreply, stream_insert(socket, :activities, activity, at: 0)} + end end diff --git a/lib/algora_web/live/onboarding/dev.ex b/lib/algora_web/live/onboarding/dev.ex index a5d4ea605..c02d42974 100644 --- a/lib/algora_web/live/onboarding/dev.ex +++ b/lib/algora_web/live/onboarding/dev.ex @@ -14,6 +14,7 @@ defmodule AlgoraWeb.Onboarding.DevLive do bounties = [status: :paid, limit: 50, solver_country: socket.assigns.current_country] |> Bounties.list_bounties() + |> Enum.filter(&Map.get(&1, :solver)) |> Enum.uniq_by(& &1.solver.id) {:ok, diff --git a/lib/algora_web/live/user/transactions_live.ex b/lib/algora_web/live/user/transactions_live.ex index f10ca9814..2b3900418 100644 --- a/lib/algora_web/live/user/transactions_live.ex +++ b/lib/algora_web/live/user/transactions_live.ex @@ -79,8 +79,16 @@ defmodule AlgoraWeb.User.TransactionsLive do def handle_event("setup_payout_account", _params, socket) do case Payments.create_account_link(socket.assigns.account, AlgoraWeb.Endpoint.url()) do - {:ok, %{url: url}} -> {:noreply, redirect(socket, external: url)} - {:error, _reason} -> {:noreply, put_flash(socket, :error, "Something went wrong")} + {:ok, %{url: url}} -> + {:noreply, redirect(socket, external: url)} + + # {:error, %Stripe.Error{} = error} -> + # Algora.Notifier.notify_stripe_account_link_error(socket.assigns.current_user, error) + # Algora.Signal.send_error(error, %StripeAccountLinkError{}) + # {:noreply, put_flash(socket, :error, "Failed to link payout account for your country")} + + {:error, _reason} -> + {:noreply, put_flash(socket, :error, "Something went wrong")} end end @@ -98,8 +106,13 @@ defmodule AlgoraWeb.User.TransactionsLive do {:ok, %{url: url}} <- Payments.create_account_link(account, AlgoraWeb.Endpoint.url()) do {:noreply, redirect(socket, external: url)} else + # {:error, %Stripe.Error{} = error} -> + # Algora.Notifier.notify_stripe_account_link_error(socket.assigns.current_user, error) + # Algora.Signal.send_error(error, %StripeAccountCreateError{}) + # {:noreply, put_flash(socket, :error, "Failed to create payout account")} + {:error, _reason} -> - {:noreply, put_flash(socket, :error, "Failed to create payout account")} + {:noreply, put_flash(socket, :error, "Something went wrong")} end else {:noreply, assign(socket, :payout_account_form, to_form(changeset))} @@ -124,11 +137,15 @@ defmodule AlgoraWeb.User.TransactionsLive do |> assign(:show_manage_payout_drawer, false) |> put_flash(:info, "Payout account deleted successfully")} + # {:error, %Stripe.Error{} = error} -> + # Algora.Signal.send_error(error, %StripeAccountDeleteError{}) + # {:noreply, put_flash(socket, :error, "Failed to delete payout account")} + {:error, _reason} -> {:noreply, socket |> assign(:show_delete_confirmation, false) - |> put_flash(:error, "Failed to delete payout account")} + |> put_flash(:error, "Something went wrong")} end end diff --git a/lib/algora_web/router.ex b/lib/algora_web/router.ex index 81819494b..767f61b57 100644 --- a/lib/algora_web/router.ex +++ b/lib/algora_web/router.ex @@ -44,6 +44,7 @@ defmodule AlgoraWeb.Router do end get "/set_context/:context", ContextController, :set + get "/a/:table_prefix/:activity_id", ActivityController, :get get "/callbacks/stripe/refresh", StripeCallbackController, :refresh get "/callbacks/stripe/return", StripeCallbackController, :return diff --git a/lib/mix/tasks/algora/create_tip.ex b/lib/mix/tasks/algora/create_tip.ex new file mode 100644 index 000000000..7ac00eb35 --- /dev/null +++ b/lib/mix/tasks/algora/create_tip.ex @@ -0,0 +1,47 @@ +defmodule Mix.Tasks.Algora.CreateTip do + @shortdoc "Creates a mock bounty" + + @moduledoc false + use Mix.Task + + import Algora.Mocks.GithubMock + + def run(args) do + Application.ensure_all_started(:algora) + Application.ensure_all_started(:mox) + Mox.defmock(Algora.GithubMock, for: Algora.Github.Behaviour) + Application.put_env(:algora, :github_client, Algora.GithubMock) + setup_get_issue() + setup_get_repository() + + opts = parse_opts(args) + from_user = Algora.Accounts.get_user_by_handle(opts[:from]) + to_user = Algora.Accounts.get_user_by_handle(opts[:to]) + amount = Money.new(opts[:amount], :USD) + + %{ + creator: from_user, + owner: from_user, + recipient: to_user, + amount: amount + } + |> Algora.Bounties.create_tip() + |> case do + {:ok, _bounty} -> + IO.puts("Tip created") + + {:error, :already_exists} -> + IO.puts("Tip already created") + end + end + + defp parse_opts(args) do + {opts, _, _} = + OptionParser.parse( + args, + strict: [from: :string, to: :string, amount: :string] + ) + + opts + end +end diff --git a/mix.exs b/mix.exs index 78b0e2fa4..5fc91e661 100644 --- a/mix.exs +++ b/mix.exs @@ -14,6 +14,14 @@ defmodule Algora.MixProject do plt_local_path: "priv/plts/project.plt", plt_core_path: "priv/plts/core.plt", ignore_warnings: ".dialyzer_ignore.exs" + ], + test_coverage: [tool: ExCoveralls], + preferred_cli_env: [ + coveralls: :test, + "coveralls.detail": :test, + "coveralls.post": :test, + "coveralls.html": :test, + "coveralls.cobertura": :test ] ] end @@ -67,7 +75,7 @@ defmodule Algora.MixProject do {:salad_ui, "~> 0.14.0"}, {:tails, "~> 0.1.5"}, {:number, "~> 1.0.1"}, - {:mox, "~> 1.0", only: :test}, + {:mox, "~> 1.0", only: [:dev, :test]}, {:tzdata, "~> 1.1"}, {:stripity_stripe, "~> 2.0"}, {:live_svelte, "~> 0.14.1"}, @@ -77,6 +85,8 @@ defmodule Algora.MixProject do {:typed_ecto_schema, "~> 0.4.1", runtime: false}, {:chameleon, "~> 2.2.0"}, {:ex_machina, "~> 2.8.0", only: [:dev, :test]}, + {:excoveralls, "~> 0.18", only: :test}, + {:dataloader, "~> 2.0.0"}, # ex_aws {:ex_aws, "~> 2.1"}, {:ex_aws_s3, "~> 2.0"}, diff --git a/mix.lock b/mix.lock index 8f4410bdd..31257ccfc 100644 --- a/mix.lock +++ b/mix.lock @@ -9,6 +9,7 @@ "chameleon": {:hex, :chameleon, "2.2.1", "7b3d745ee1abfea26c0160590cabe3d102e7e020bba0c405176b3464c90f42a8", [:mix], [], "hexpm", "2643ececff1824793d607551418c8f79bee01395c13cf14530b27acb2903c6e5"}, "cldr_utils": {:hex, :cldr_utils, "2.28.2", "f500667164a9043369071e4f9dcef31f88b8589b2e2c07a1eb9f9fa53cb1dce9", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "c506eb1a170ba7cdca59b304ba02a56795ed119856662f6b1a420af80ec42551"}, "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"}, + "dataloader": {:hex, :dataloader, "2.0.2", "c45075e0692e68638a315e14f747bd8d7065fb5f38705cf980f62d4cd344401f", [:mix], [{:ecto, ">= 3.4.3 and < 4.0.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:opentelemetry_process_propagator, "~> 0.2.1 or ~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4c6cabc0b55e96e7de74d14bf37f4a5786f0ab69aa06764a1f39dda40079b098"}, "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "decorator": {:hex, :decorator, "1.4.0", "a57ac32c823ea7e4e67f5af56412d12b33274661bb7640ec7fc882f8d23ac419", [:mix], [], "hexpm", "0a07cedd9083da875c7418dea95b78361197cf2bf3211d743f6f7ce39656597f"}, @@ -27,6 +28,7 @@ "ex_machina": {:hex, :ex_machina, "2.8.0", "a0e847b5712065055ec3255840e2c78ef9366634d62390839d4880483be38abe", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "79fe1a9c64c0c1c1fab6c4fa5d871682cb90de5885320c187d117004627a7729"}, "ex_money": {:hex, :ex_money, "5.19.0", "c4c18f097d148f1d02793ac4e2fc63d11422bf40eda5d78031226e0e96982eaa", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ex_cldr_numbers, "~> 2.33", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}, {:gringotts, "~> 1.1", [hex: :gringotts, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.0 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "44d296a2ba42d3f03aa76efa8b5618abdd6c6c6bce747b8351406d65249648ea"}, "ex_money_sql": {:hex, :ex_money_sql, "1.11.0", "1b9b2f920d5d9220fa6dd4d8aae258daf562deaed2fb037b38b1f7ba4d0a344c", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ex_money, "~> 5.7", [hex: :ex_money, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.15", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "629e0541ae9f87122d34650f8c8febbc7349bbc6f881cf7a51b4d0779886107d"}, + "excoveralls": {:hex, :excoveralls, "0.18.3", "bca47a24d69a3179951f51f1db6d3ed63bca9017f476fe520eb78602d45f7756", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "746f404fcd09d5029f1b211739afb8fb8575d775b21f6a3908e7ce3e640724c6"}, "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, diff --git a/priv/repo/migrations/20250109215504_create_activities.exs b/priv/repo/migrations/20250109215504_create_activities.exs new file mode 100644 index 000000000..49309ca75 --- /dev/null +++ b/priv/repo/migrations/20250109215504_create_activities.exs @@ -0,0 +1,29 @@ +defmodule Algora.Repo.Migrations.CreateActivities do + use Ecto.Migration + + require Algora.Activities + + def change do + Enum.each(Algora.Activities.tables(), fn table_name -> + create table(table_name) do + add :assoc_id, :string, null: false + add :user_id, references(:users) + add :type, :string, null: false + add :visibility, :string, null: false + add :template, :string + add :meta, :map, null: false + add :changes, :map, null: false + add :trace_id, :string + add :previous_event_id, references(table_name) + add :notify_users, {:array, :string}, default: [] + + timestamps() + end + + create index(table_name, [:assoc_id]) + create index(table_name, [:user_id]) + create index(table_name, [:trace_id]) + create index(table_name, [:visibility]) + end) + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index f3eb2d30a..67cc0bf7b 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -39,6 +39,11 @@ defmodule Algora.Stripe.SeedImpl do def create_transfer(_params) do {:ok, %{id: "tr_#{Nanoid.generate()}"}} end + + @impl true + def create_session(_params) do + {:ok, %{url: "https://#{Nanoid.generate()}.example.com"}} + end end github_id = diff --git a/test/algora/accounts_test.exs b/test/algora/accounts_test.exs new file mode 100644 index 000000000..07a90ec11 --- /dev/null +++ b/test/algora/accounts_test.exs @@ -0,0 +1,39 @@ +defmodule Algora.AccountsTest do + use Algora.DataCase + + alias Algora.Accounts + + describe "accounts" do + test "register github user" do + email = "githubuser@example.com" + + info = %{ + "id" => "1234", + "login" => "githubuser", + "name" => "Github User" + } + + {:ok, user} = Accounts.register_github_user(email, info, [email], "token123") + {:ok, user_again} = Accounts.register_github_user(email, info, [email], "token123") + + assert_activity_names([:identity_created]) + assert_activity_names_for_user(user.id, [:identity_created]) + assert_activity_names_for_user(user_again.id, [:identity_created]) + end + + test "query" do + user_1 = insert(:user) + user_2 = insert(:user, tech_stack: ["rust", "c++"]) + org_1 = insert(:organization, seeded: false) + + assert user_1.id |> Accounts.fetch_developer() |> elem(1) |> Map.get(:id) == user_1.id + assert [sort_by_tech_stack: ["rust"]] |> Accounts.fetch_developer_by() |> elem(1) |> Map.get(:id) == user_2.id + + assert [] |> Accounts.list_developers() |> length() == 2 + assert [] |> Accounts.list_orgs() |> length() == 1 + + assert_activity_names_for_user(user_1.id, []) + assert_activity_names_for_user(org_1.id, []) + end + end +end diff --git a/test/algora/analytics_test.exs b/test/algora/analytics_test.exs new file mode 100644 index 000000000..513c090b3 --- /dev/null +++ b/test/algora/analytics_test.exs @@ -0,0 +1,97 @@ +defmodule Algora.AnalyticsTest do + use Algora.DataCase + + import Algora.Factory + + alias Algora.Analytics + + setup do + now = DateTime.utc_now() + last_month = DateTime.add(now, -40 * 24 * 3600) + + Enum.reduce(1..100, now, fn _n, date -> + insert(:organization, %{inserted_at: date, seeded: false, activated: false}) + DateTime.add(date, -1 * 24 * 3600) + end) + + Enum.reduce(1..100, now, fn _n, date -> + org = insert(:organization, %{inserted_at: date, seeded: true, activated: true}) + insert_list(2, :contract, %{client_id: org.id, status: :active}) + insert_list(1, :contract, %{client_id: org.id, status: :paid}) + insert_list(3, :contract, %{client_id: org.id, status: :cancelled}) + insert_list(1, :contract, %{inserted_at: last_month, client_id: org.id, status: :paid}) + insert_list(3, :contract, %{inserted_at: last_month, client_id: org.id, status: :cancelled}) + DateTime.add(date, -1 * 24 * 3600) + end) + + :ok + end + + describe "analytics" do + test "get_company_analytics 30d" do + {:ok, resp} = Analytics.get_company_analytics("30d") + assert resp.total_companies == 200 + assert resp.active_companies == 100 + assert resp.companies_change == 60 + assert resp.active_change == 30 + assert resp.companies_trend == :same + assert resp.active_trend == :same + assert resp.contract_success_rate == 50.0 + assert resp.success_rate_change == 25.0 + assert resp.success_rate_trend == :up + + assert length(resp.companies) > 0 + + assert %{total_contracts: 6, successful_contracts: 3, success_rate: 50.0, last_active_at: last_active_at} = + List.first(resp.companies) + + assert DateTime.before?(last_active_at, DateTime.utc_now()) + + now = DateTime.utc_now() + last_month = DateTime.add(now, -40 * 24 * 3600) + insert(:organization, %{inserted_at: last_month, seeded: true, activated: true}) + + {:ok, resp} = Analytics.get_company_analytics("30d") + assert resp.total_companies == 201 + assert resp.active_companies == 101 + assert resp.companies_change == 60 + assert resp.active_change == 30 + assert resp.companies_trend == :down + assert resp.active_trend == :down + + insert(:organization, %{seeded: true, activated: true}) + insert(:organization, %{seeded: true, activated: true}) + insert(:organization, %{seeded: false, activated: false}) + + {:ok, resp} = Analytics.get_company_analytics("30d") + + assert resp.total_companies == 204 + assert resp.active_companies == 103 + assert resp.companies_change == 63 + assert resp.active_change == 32 + assert resp.companies_trend == :up + assert resp.active_trend == :up + end + + test "get_company_analytics 356d" do + {:ok, resp} = Analytics.get_company_analytics("365d") + assert resp.total_companies == 200 + assert resp.active_companies == 100 + assert resp.companies_change == 200 + assert resp.active_change == 100 + assert resp.companies_trend == :up + assert resp.active_trend == :up + end + + test "get_company_analytics 7d" do + insert(:organization, %{seeded: true, activated: true}) + {:ok, resp} = Analytics.get_company_analytics("7d") + assert resp.total_companies == 201 + assert resp.active_companies == 101 + assert resp.companies_change == 15 + assert resp.active_change == 8 + assert resp.companies_trend == :up + assert resp.active_trend == :up + end + end +end diff --git a/test/algora/bounties_test.exs b/test/algora/bounties_test.exs new file mode 100644 index 000000000..d00f704ef --- /dev/null +++ b/test/algora/bounties_test.exs @@ -0,0 +1,153 @@ +defmodule Algora.BountiesTest do + use Algora.DataCase + use Oban.Testing, repo: Algora.Repo + + import Algora.Factory + import Money.Sigil + + alias Algora.Activities.Notifier + alias Algora.Activities.SendEmail + alias Algora.Activities.Views + + def setup_github_mocks(_context) do + import Algora.Mocks.GithubMock + + setup_installation_token() + setup_repository_permissions() + setup_create_issue_comment() + setup_get_user_by_username() + setup_get_issue() + setup_get_repository() + :ok + end + + def setup_stripe_mocks(_context) do + import Algora.Mocks.StripeMock + + setup_create_session() + :ok + end + + describe "bounties" do + setup [:setup_github_mocks, :setup_stripe_mocks] + + test "create" do + creator = insert!(:user) + owner = insert!(:user) + recipient = insert!(:user) + installation = insert!(:installation, owner: creator) + _installation = insert!(:installation, owner: owner) + _installation = insert!(:installation, owner: recipient) + _identity = insert!(:identity, user: creator, provider_email: creator.email) + repo = insert!(:repository, %{user: owner}) + ticket = insert!(:ticket, %{repository: repo}) + amount = ~M[4000]usd + + ticket_ref = %{ + owner: owner.handle, + repo: repo.name, + number: ticket.number + } + + bounty_params = + %{ + ticket_ref: ticket_ref, + owner: owner, + creator: creator, + amount: amount + } + + assert {:ok, bounty} = Algora.Bounties.create_bounty(bounty_params, []) + + assert {:ok, claim} = + Algora.Bounties.claim_bounty( + %{ + user: recipient, + target_ticket_ref: ticket_ref, + source_ticket_ref: ticket_ref, + status: :approved, + type: :pull_request + }, + installation_id: installation.id + ) + + claim = Algora.Repo.one(Algora.Bounties.Claim.preload(claim.id)) + + assert {:ok, _bounty} = + Algora.Bounties.reward_bounty( + %{ + owner: owner, + amount: ~M[4000]usd, + bounty_id: bounty.id, + claims: [claim] + }, + installation_id: installation.id + ) + + assert {:ok, _stripe_session_url} = + Algora.Bounties.create_tip( + %{ + amount: amount, + owner: owner, + creator: creator, + recipient: recipient + }, + ticket_ref: ticket_ref, + claims: [claim] + ) + + assert_activity_names([:bounty_posted, :claim_submitted, :bounty_awarded, :tip_awarded]) + assert_activity_names_for_user(creator.id, [:bounty_posted, :bounty_awarded, :tip_awarded]) + assert_activity_names_for_user(recipient.id, [:claim_submitted, :tip_awarded]) + + assert [bounty, claim, awarded, tip] = Enum.reverse(Algora.Activities.all()) + assert "tip_activities" == tip.assoc_name + assert tip.notify_users == [recipient.id] + assert activity = Algora.Activities.get_with_preloaded_assoc(tip.assoc_name, tip.id) + assert activity.assoc.__meta__.schema == Algora.Bounties.Tip + assert activity.assoc.creator.id == creator.id + + assert_enqueued(worker: Notifier, args: %{"activity_id" => bounty.id}) + refute_enqueued(worker: SendEmail, args: %{"activity_id" => bounty.id}) + + Enum.map(all_enqueued(worker: Notifier), fn job -> + perform_job(Notifier, job.args) + end) + + assert_enqueued(worker: SendEmail, args: %{"activity_id" => bounty.id}) + end + + test "query" do + {:ok, bounty} = + Enum.reduce(1..10, nil, fn _n, _acc -> + creator = insert!(:user) + owner = insert!(:user) + _installation = insert!(:installation, owner: creator) + _identity = insert!(:identity, user: creator, provider_email: creator.email) + repo = insert!(:repository, %{user: owner}) + ticket = insert!(:ticket, %{repository: repo}) + amount = ~M[100]usd + + bounty_params = + %{ + ticket_ref: %{owner: owner.handle, repo: repo.name, number: ticket.number}, + owner: owner, + creator: creator, + amount: amount + } + + Algora.Bounties.create_bounty(bounty_params, []) + end) + + assert Algora.Bounties.list_bounties( + owner_id: bounty.owner_id, + tech_stack: ["elixir"], + status: :open + ) + + # assert Algora.Bounties.fetch_stats(bounty.owner_id) + # assert Algora.Bounties.fetch_stats() + assert Algora.Bounties.PrizePool.list() + end + end +end diff --git a/test/algora/chat_test.exs b/test/algora/chat_test.exs new file mode 100644 index 000000000..1c5a0088f --- /dev/null +++ b/test/algora/chat_test.exs @@ -0,0 +1,31 @@ +defmodule Algora.ChatTest do + use Algora.DataCase + use Oban.Testing, repo: Algora.Repo + + alias Algora.Chat + + describe "chat" do + test "direct" do + user_1 = insert(:user) + user_2 = insert(:user) + {:ok, thread} = Chat.create_direct_thread(user_1, user_2) + {:ok, message_1} = Chat.send_message(thread.id, user_1.id, "hello") + {:ok, message_2} = Chat.send_message(thread.id, user_2.id, "there") + assert thread.id |> Chat.list_messages() |> Enum.map(& &1.id) == [message_1.id, message_2.id] + assert Chat.mark_as_read(thread.id, user_1.id) == {1, nil} + assert Chat.get_thread_for_users(user_1.id, user_2.id).id == thread.id + assert user_1.id |> Chat.list_threads() |> Enum.map(& &1.id) == [thread.id] + assert user_2.id |> Chat.list_threads() |> Enum.map(& &1.id) == [thread.id] + end + + test "contract" do + client = insert(:user) + contractor = insert(:user) + contract = insert(:contract, client: client, contractor: contractor) + thread = Chat.get_or_create_thread!(contract) + assert Chat.get_or_create_thread!(contract).id == thread.id + assert client.id |> Chat.list_threads() |> Enum.map(& &1.id) == [thread.id] + assert contractor.id |> Chat.list_threads() |> Enum.map(& &1.id) == [thread.id] + end + end +end diff --git a/test/algora/contracts_test.exs b/test/algora/contracts_test.exs index 5c8f12b46..9657a28c9 100644 --- a/test/algora/contracts_test.exs +++ b/test/algora/contracts_test.exs @@ -1,10 +1,13 @@ defmodule Algora.ContractsTest do use Algora.DataCase + use Oban.Testing, repo: Algora.Repo import Algora.Factory import Money.Sigil + alias Algora.Activities alias Algora.Contracts + alias Algora.Contracts.Contract alias Algora.Payments alias Algora.Payments.Transaction @@ -175,6 +178,87 @@ defmodule Algora.ContractsTest do assert Money.equal?(contract3.total_transferred, ~M[9_000]usd) end + test "produces activities" do + # Group a + contract_a_0 = setup_contract(%{hourly_rate: ~M[100]usd, hours_per_week: 30}) + {:ok, _txs0} = Contracts.prepay_contract(contract_a_0) + + # First cycle + insert!(:timesheet, %{contract_id: contract_a_0.id, hours_worked: 30}) + {:ok, contract_a_0} = Contracts.fetch_contract(contract_a_0.id) + {:ok, {_txs1, contract_a_1}} = Contracts.release_and_renew_contract(contract_a_0) + + # Second cycle + insert!(:timesheet, %{contract_id: contract_a_1.id, hours_worked: 20}) + {:ok, contract_a_1} = Contracts.fetch_contract(contract_a_1.id) + {:ok, {_txs2, _contract_a_2}} = Contracts.release_and_renew_contract(contract_a_1) + + # Group b + contract_b_0 = setup_contract(%{hourly_rate: ~M[100]usd, hours_per_week: 30}) + {:ok, _txs0} = Contracts.prepay_contract(contract_b_0) + + # First cycle + insert!(:timesheet, %{contract_id: contract_b_0.id, hours_worked: 30}) + {:ok, contract_b_0} = Contracts.fetch_contract(contract_b_0.id) + {:ok, {_txs1, contract_b_1}} = Contracts.release_and_renew_contract(contract_b_0) + + # Second cycle + insert!(:timesheet, %{contract_id: contract_b_1.id, hours_worked: 20}) + {:ok, contract_b_1} = Contracts.fetch_contract(contract_b_1.id) + {:ok, {_txs2, _contract_b_2}} = Contracts.release_and_renew_contract(contract_b_1) + + assert_activity_names( + contract_a_0, + [:contract_prepaid, :contract_paid] + ) + + assert_activity_names( + contract_a_1, + [:contract_renewed, :contract_paid] + ) + + assert_activity_names( + "contract_activities", + [ + :contract_prepaid, + :contract_paid, + :contract_renewed, + :contract_paid, + :contract_renewed, + :contract_prepaid, + :contract_paid, + :contract_renewed, + :contract_paid, + :contract_renewed + ] + ) + + assert_activity_names_for_user( + contract_a_0.contractor_id, + [ + :contract_prepaid, + :contract_paid, + :contract_renewed, + :contract_paid, + :contract_renewed + ] + ) + + assert Activities.all_for_user(contract_a_0.client_id) != + Activities.all_for_user(contract_b_0.client_id) + + assert Activities.all_for_user(contract_a_0.contractor_id) != + Activities.all_for_user(contract_b_0.contractor_id) + + assert_enqueued(worker: Activities.Notifier, worker: "Algora.Activities.Notifier") + + assert [contract_activity | activities] = Enum.reverse(Activities.all()) + assert contract_activity.assoc.id == contract_a_0.id + activity = Activities.get(contract_activity.assoc_name, contract_activity.id) + assert activity.assoc.__meta__.schema == Contract + assert List.last(activities).notify_users == [contract_b_1.client.id, contract_b_1.contractor_id] + end + test "prepayment fails when payment method is invalid" do contract = setup_contract(%{hourly_rate: ~M[100]usd, hours_per_week: 40}) diff --git a/test/algora/onboarding_test.exs b/test/algora/organizations_test.exs similarity index 96% rename from test/algora/onboarding_test.exs rename to test/algora/organizations_test.exs index 6506728e0..b277dd243 100644 --- a/test/algora/onboarding_test.exs +++ b/test/algora/organizations_test.exs @@ -1,4 +1,4 @@ -defmodule Algora.OnboardingTest do +defmodule Algora.OrganizationsTest do use Algora.DataCase @params %{ @@ -72,8 +72,8 @@ defmodule Algora.OnboardingTest do } } - describe "onboarding" do - test "create" do + describe "organizations" do + test "onboard" do assert {:ok, %{user: user, org: org, member: member, contract: contract}} = Algora.Organizations.onboard_organization(@params) @@ -88,7 +88,7 @@ defmodule Algora.OnboardingTest do assert org.display_name == "Algora" end - test "create with crawler" do + test "onboard with crawler" do assert {:ok, %{user: user, org: org, member: _member, contract: _contract}} = Algora.Organizations.onboard_organization(@params_crawler) diff --git a/test/algora/reviews_test.exs b/test/algora/reviews_test.exs new file mode 100644 index 000000000..f9f0db416 --- /dev/null +++ b/test/algora/reviews_test.exs @@ -0,0 +1,30 @@ +defmodule Algora.ReviewsTest do + use Algora.DataCase + use Oban.Testing, repo: Algora.Repo + + alias Algora.Reviews + + describe "reviews" do + test "create" do + reviewer = insert(:user) + reviewee = insert(:user) + org = insert(:organization) + contract = insert(:contract, client: reviewer, contractor: reviewee) + + {:ok, review} = + Reviews.create_review(%{ + rating: 5, + content: "NICE!", + visibility: :public, + contract_id: contract.id, + organization_id: org.id, + reviewee_id: reviewee.id, + reviewer_id: reviewer.id + }) + + assert [reviewee_id: reviewee.id] + |> Reviews.list_reviews() + |> Enum.map(& &1.id) == [review.id] + end + end +end diff --git a/test/support/data_case.ex b/test/support/data_case.ex index 39d30e04b..3f04419c6 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -13,7 +13,6 @@ defmodule Algora.DataCase do by setting `use Algora.DataCase, async: true`, although this option is not recommended for other databases. """ - use ExUnit.CaseTemplate alias Ecto.Adapters.SQL.Sandbox @@ -21,9 +20,11 @@ defmodule Algora.DataCase do using do quote do import Algora.DataCase + import Algora.Factory import Ecto import Ecto.Changeset import Ecto.Query + import Money.Sigil alias Algora.Repo end @@ -57,4 +58,24 @@ defmodule Algora.DataCase do end) end) end + + def assert_activity_names(names) do + assert Algora.Activities.all() + |> Enum.reverse() + |> Enum.map(&Map.get(&1, :type)) == names + end + + def assert_activity_names(target, names) do + assert target + |> Algora.Activities.all() + |> Enum.reverse() + |> Enum.map(&Map.get(&1, :type)) == names + end + + def assert_activity_names_for_user(user_id, names) do + assert user_id + |> Algora.Activities.all_for_user() + |> Enum.reverse() + |> Enum.map(&Map.get(&1, :type)) == names + end end diff --git a/test/support/factory.ex b/test/support/factory.ex index a7de935cd..e25deae72 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -10,7 +10,10 @@ defmodule Algora.Factory do %Algora.Accounts.Identity{ id: Nanoid.generate(), provider: "github", - provider_token: "" + provider_id: sequence(:provider_id, &"identity#{&1}"), + provider_token: sequence(:provider_token, &"token#{&1}"), + provider_email: sequence(:provider_email, &"identity#{&1}@example.com"), + provider_login: sequence(:provider_login, &"identity#{&1}") } end @@ -127,7 +130,8 @@ defmodule Algora.Factory do hours_per_week: 40, sequence_number: 1, start_date: days_from_now(0), - end_date: days_from_now(7) + end_date: days_from_now(7), + activities: [] } end @@ -169,7 +173,7 @@ defmodule Algora.Factory do %Algora.Workspace.Repository{ id: Nanoid.generate(), provider: "github", - provider_id: sequence(:provider_id, &"#{&1}"), + provider_id: sequence(:provider_id, &"repository#{&1}"), name: "middle-out", url: "https://github.com/piedpiper/middle-out", og_image_url: "https://algora.io/asset/storage/v1/object/public/mock/piedpiper-banner.jpg", @@ -181,7 +185,7 @@ defmodule Algora.Factory do %Algora.Workspace.Ticket{ id: Nanoid.generate(), provider: "github", - provider_id: sequence(:provider_id, &"#{&1}"), + provider_id: sequence(:provider_id, &"ticket#{&1}"), type: :issue, title: "Optimize compression algorithm for large files", description: "We need to improve performance when handling files over 1GB", @@ -220,8 +224,8 @@ defmodule Algora.Factory do %Installation{ id: Nanoid.generate(), provider: "github", - provider_id: sequence(:provider_id, &"#{&1}"), - provider_user_id: sequence(:provider_user_id, &"#{&1}"), + provider_id: sequence(:provider_id, &"installation#{&1}"), + provider_user_id: sequence(:provider_user_id, &"installation#{&1}"), provider_meta: %{ "account" => %{"avatar_url" => "https://algora.io/asset/storage/v1/object/public/mock/piedpiper-logo.png"}, "repository_selection" => "selected" diff --git a/test/support/mocks/github_mock.ex b/test/support/mocks/github_mock.ex new file mode 100644 index 000000000..7a2296c79 --- /dev/null +++ b/test/support/mocks/github_mock.ex @@ -0,0 +1,70 @@ +defmodule Algora.Mocks.GithubMock do + @moduledoc false + import Mox + + def setup_installation_token do + stub( + Algora.GithubMock, + :get_installation_token, + fn _installation_id -> {:ok, "mock-token"} end + ) + end + + def setup_repository_permissions do + stub( + Algora.GithubMock, + :get_repository_permissions, + fn _token, _owner, _repo, _user -> {:ok, %{"permission" => "none"}} end + ) + end + + def setup_create_issue_comment do + stub( + Algora.GithubMock, + :create_issue_comment, + fn _token, _owner, _repo, _issue_number, _body -> {:ok, %{"id" => random_id()}} end + ) + end + + def setup_get_user_by_username do + stub( + Algora.GithubMock, + :get_user_by_username, + fn _token, username -> {:ok, %{"id" => random_id(), "login" => username}} end + ) + end + + def setup_get_issue do + stub( + Algora.GithubMock, + :get_issue, + fn _token, owner, repo, issue_number -> + {:ok, + %{ + "id" => random_id(), + "number" => issue_number, + "title" => "Test Issue", + "body" => "Test body", + "html_url" => "https://github.com/#{owner}/#{repo}/issues/#{issue_number}" + }} + end + ) + end + + def setup_get_repository do + stub( + Algora.GithubMock, + :get_repository, + fn _token, owner, repo -> + {:ok, + %{ + "id" => random_id(), + "name" => repo, + "html_url" => "https://github.com/#{owner}/#{repo}" + }} + end + ) + end + + defp random_id(n \\ 1000), do: :rand.uniform(n) +end diff --git a/test/support/mocks/stripe_mock.ex b/test/support/mocks/stripe_mock.ex new file mode 100644 index 000000000..fb5415107 --- /dev/null +++ b/test/support/mocks/stripe_mock.ex @@ -0,0 +1,12 @@ +defmodule Algora.Mocks.StripeMock do + @moduledoc false + import Mox + + def setup_create_session do + stub( + Algora.StripeMock, + :create_session, + fn _a -> {:ok, %{url: "https://example.com/stripe"}} end + ) + end +end