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""" +