diff --git a/.formatter.exs b/.formatter.exs index 8690b3d..c404978 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,5 +1,6 @@ [ import_deps: [ + :cinder, :ash_oban, :oban, :ash_authentication_phoenix, diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js index 4ed8383..e454c94 100644 --- a/assets/tailwind.config.js +++ b/assets/tailwind.config.js @@ -7,6 +7,7 @@ const path = require("path") module.exports = { content: [ + "../deps/cinder/lib/**/*.*ex", "../deps/ash_authentication_phoenix/**/*.*ex", "./js/**/*.js", "../lib/helpcenter_web.ex", diff --git a/config/config.exs b/config/config.exs index 28e4c6d..ea775ab 100644 --- a/config/config.exs +++ b/config/config.exs @@ -7,6 +7,7 @@ # General application configuration import Config +config :cinder, default_theme: "modern" config :ash_oban, pro?: false config :helpcenter, Oban, diff --git a/lib/helpcenter/accounts.ex b/lib/helpcenter/accounts.ex index 878d65b..7c2d56d 100644 --- a/lib/helpcenter/accounts.ex +++ b/lib/helpcenter/accounts.ex @@ -3,15 +3,16 @@ defmodule Helpcenter.Accounts do use Ash.Domain, otp_app: :helpcenter resources do - # Authentication - resource Helpcenter.Accounts.Token - resource Helpcenter.Accounts.User resource Helpcenter.Accounts.Team - resource Helpcenter.Accounts.UserTeam - + resource Helpcenter.Accounts.User resource Helpcenter.Accounts.Group - resource Helpcenter.Accounts.GroupPermission + resource Helpcenter.Accounts.UserTeam resource Helpcenter.Accounts.UserGroup + resource Helpcenter.Accounts.GroupPermission + + resource Helpcenter.Accounts.Token + resource Helpcenter.Accounts.Invitation + resource Helpcenter.Accounts.UserNotification do define :notify, action: :create diff --git a/lib/helpcenter/accounts/group.ex b/lib/helpcenter/accounts/group.ex index 741e0c2..a1e7301 100644 --- a/lib/helpcenter/accounts/group.ex +++ b/lib/helpcenter/accounts/group.ex @@ -10,12 +10,15 @@ defmodule Helpcenter.Accounts.Group do repo Helpcenter.Repo end + code_interface do + define :list_groups, action: :read + end + actions do default_accept [:name, :description] defaults [:create, :read, :update, :destroy] end - # Confirm how Ash will wor pub_sub do module HelpcenterWeb.Endpoint diff --git a/lib/helpcenter/accounts/invitation.ex b/lib/helpcenter/accounts/invitation.ex new file mode 100644 index 0000000..a111a9b --- /dev/null +++ b/lib/helpcenter/accounts/invitation.ex @@ -0,0 +1,150 @@ +# lib/helpcenter/accounts/invitation.ex +defmodule Helpcenter.Accounts.Invitation do + use Ash.Resource, + domain: Helpcenter.Accounts, + data_layer: AshPostgres.DataLayer, + notifiers: Ash.Notifier.PubSub + + postgres do + table "invitations" + repo Helpcenter.Repo + end + + code_interface do + define :accept, action: :accept + define :get_by_token, args: [:token], action: :by_token + end + + actions do + defaults [:read, :destroy] + + create :create do + description """ + This action assumes that inserting new data == inviting a new users. It will then: + 1. Generate a unique token for the invitation and add it to the changeset + 2. Set new invitation attributes such as: expires_at, team, etc. + 3. Sends an invitation to the newly invited user via email + """ + + accept [:email, :group_id] + change Helpcenter.Accounts.Invitation.Changes.SetInvitationAttributes + change Helpcenter.Accounts.Invitation.Changes.SendInvitationEmail + end + + read :by_token do + description "Get one invitation by its token" + argument :token, :string + filter expr(token == ^arg(:token)) + get? true + end + + update :accept do + description """ + When an invitee accepts invitation, this action will be called: + 1. Add invitee to the team based on the invitation data + 2. Add invitee to the permission group based on the invitation data + 3. Send invitee a welcome email to the newly added user to the team + """ + + accept [] + + validate Helpcenter.Accounts.Invitation.Validations.EnsurePendingStatus + + change atomic_update(:status, :accepted) + change Helpcenter.Accounts.Invitation.Changes.AddUserToTeam + change Helpcenter.Accounts.Invitation.Changes.SendWelcomeEmail + end + + update :decline do + description """ + When an invitee declines invitation, this action will be called: + 1. Changes the status to the declined. + 2. Send a decline email. + """ + + accept [] + + validate Helpcenter.Accounts.Invitation.Validations.EnsurePendingStatus + + change set_attribute(:status, :declined) + change Helpcenter.Accounts.Invitation.Changes.SendDeclinedEmail + end + end + + # Confirm how Ash will wor + pub_sub do + module HelpcenterWeb.Endpoint + prefix "invitations" + publish_all :update, [[:id, :team, nil]] + publish_all :create, [[:id, :team, nil]] + publish_all :destroy, [[:id, :team, nil]] + end + + preparations do + prepare Helpcenter.Preparations.SetTenant + prepare Helpcenter.Accounts.Invitation.Preparations.ForCurrentTeam + end + + changes do + change Helpcenter.Changes.SetTenant + end + + multitenancy do + strategy :context + global? true + end + + attributes do + uuid_primary_key :id + + attribute :email, :string do + allow_nil? false + constraints match: ~r/^[^\s]+@[^\s]+\.[^\s]+$/ + description "Email address of the user to invite" + end + + attribute :status, :atom do + default :pending + allow_nil? false + constraints one_of: [:pending, :accepted, :declined] + description "The status of the invitation sent to the user" + end + + attribute :token, :string do + allow_nil? false + description "The token in the URL to identify this invitation" + end + + attribute :team, :string do + allow_nil? false + description "The team the user will be added to after accepting invitation" + end + + attribute :expires_at, :utc_datetime do + allow_nil? false + description "The time this invitation will expire. Default 30 days" + end + + timestamps() + end + + relationships do + belongs_to :group, Helpcenter.Accounts.Group do + allow_nil? false + source_attribute :group_id + description "User permission group the invitee will be added to" + end + + belongs_to :inviter, Helpcenter.Accounts.User do + allow_nil? false + source_attribute :inviter_user_id + description "The user who sent this invitation to the new joiner" + end + + belongs_to :invitee, Helpcenter.Accounts.User do + allow_nil? true + source_attribute :invitee_user_id + description "The invited user. This will not be nil if the user already exists in the app" + end + end +end diff --git a/lib/helpcenter/accounts/invitation/changes/add_user_to_team.ex b/lib/helpcenter/accounts/invitation/changes/add_user_to_team.ex new file mode 100644 index 0000000..af563be --- /dev/null +++ b/lib/helpcenter/accounts/invitation/changes/add_user_to_team.ex @@ -0,0 +1,108 @@ +# lib/helpcenter/accounts/invitation/changes/add_user_to_team.ex +defmodule Helpcenter.Accounts.Invitation.Changes.AddUserToTeam do + @moduledoc """ + An Ash Resource Change that adds a user to a team and permission group. + + This module handles the process of linking a user to a team, setting their + current team, and adding them to a permission group after accepting an invitation. + It uses Ash's seeding and querying capabilities for reliable data operations. + """ + + use Ash.Resource.Change + require Ash.Query + + @doc """ + Registers an after_action callback to link the user to the team after + the changeset is processed. + """ + @impl true + def change(changeset, _opts, _context) do + Ash.Changeset.after_action(changeset, &link_user_to_team/2) + end + + @impl true + def atomic(changeset, opts, context) do + {:ok, change(changeset, opts, context)} + end + + @doc """ + Links a user to a team and permission group based on the invitation. + + Retrieves or creates the user, associates them with the team, sets the + current team, and adds them to the specified permission group. + + ## Parameters + - _changeset: The Ash changeset (unused, kept for hook compatibility) + - invitation: The invitation struct containing email, team, and group_id + + ## Returns + - {:ok, invitation} on successful linking + - {:error, reason} if any operation fails + """ + def link_user_to_team(_changeset, invitation) do + with {:ok, user} <- get_or_create_user(invitation), + {:ok, _user_team} <- add_user_to_team(user.id, invitation.team), + {:ok, _user_updated} <- set_user_current_team(user, invitation.team), + {:ok, _user_group} <- add_user_to_group(user.id, invitation.group_id, invitation.team) do + {:ok, invitation} + else + {:error, reason} -> + {:error, "Failed to link user to team: #{inspect(reason)}"} + end + end + + defp get_team(team_name) do + Helpcenter.Accounts.Team + |> Ash.Query.filter(domain == ^team_name) + |> Ash.read_first(authorize?: false) + |> case do + {:ok, team} -> {:ok, team} + {:error, reason} -> {:error, reason} + end + end + + defp add_user_to_team(user_id, team_name) do + with {:ok, team} <- get_team(team_name) do + Ash.Seed.seed!( + Helpcenter.Accounts.UserTeam, + %{user_id: user_id, team_id: team.id}, + tenant: team_name + ) + |> then(&{:ok, &1}) + end + end + + defp set_user_current_team(user, team_name) do + Ash.Seed.update!(user, %{current_team: team_name}, tenant: team_name) + |> then(&{:ok, &1}) + end + + defp add_user_to_group(user_id, group_id, team_name) do + Ash.Seed.seed!( + Helpcenter.Accounts.UserGroup, + %{user_id: user_id, group_id: group_id}, + tenant: team_name + ) + |> then(&{:ok, &1}) + end + + defp get_or_create_user(%{email: email, team: team_name}) do + Helpcenter.Accounts.User + |> Ash.Query.filter(email == ^email) + |> Ash.read_first(authorize?: false) + |> case do + {:ok, nil} -> create_user(email, team_name) + {:ok, user} -> {:ok, user} + {:error, reason} -> {:error, reason} + end + end + + defp create_user(email, team_name) do + Ash.Seed.seed!( + Helpcenter.Accounts.User, + %{email: email, current_team: team_name}, + tenant: team_name + ) + |> then(&{:ok, &1}) + end +end diff --git a/lib/helpcenter/accounts/invitation/changes/send_declined_email.ex b/lib/helpcenter/accounts/invitation/changes/send_declined_email.ex new file mode 100644 index 0000000..9352b30 --- /dev/null +++ b/lib/helpcenter/accounts/invitation/changes/send_declined_email.ex @@ -0,0 +1,67 @@ +# lib/helpcenter/accounts/invitation/changes/send_declined_email.ex +defmodule Helpcenter.Accounts.Invitation.Changes.SendDeclinedEmail do + @moduledoc """ + An Ash Resource Change that sends a declination email after an invitation is declined. + + This module handles the process of sending an email notification to the user + who declined a team invitation. It integrates with Oban for async email delivery. + """ + + use Ash.Resource.Change + use HelpcenterWeb, :verified_routes + + @doc """ + Implements the change hook for Ash, registering an after_action callback + to send the declination email. + """ + @impl true + def change(changeset, _opts, _context) do + Ash.Changeset.after_action(changeset, &send_email/2) + end + + @impl true + def atomic(changeset, opts, context) do + {:ok, change(changeset, opts, context)} + end + + defp send_email(_changeset, invitation) do + with {:ok, email_data} <- build_email_data(invitation), + {:ok, _job} <- schedule_email(email_data) do + {:ok, invitation} + else + {:error, reason} -> + {:error, "Failed to send declination email: #{inspect(reason)}"} + end + end + + defp build_email_data(%{email: email, token: token, team: team}) do + message = build_email_body(team) + + email_data = %{ + id: token, + params: %{ + html_message: message, + text_message: message, + subject: "You declined an invitation to join #{team}", + from: nil, + to: email + } + } + + {:ok, email_data} + end + + defp build_email_body(team) do + """ +

Hello,

+

You have declined the invitation to join the #{team} team.

+

If this was a mistake, please contact the team administrator.

+ """ + end + + defp schedule_email(email_data) do + email_data + |> Helpcenter.Workers.EmailSender.new() + |> Oban.insert() + end +end diff --git a/lib/helpcenter/accounts/invitation/changes/send_invitation_email.ex b/lib/helpcenter/accounts/invitation/changes/send_invitation_email.ex new file mode 100644 index 0000000..ac9bf47 --- /dev/null +++ b/lib/helpcenter/accounts/invitation/changes/send_invitation_email.ex @@ -0,0 +1,75 @@ +# lib/helpcenter/accounts/invitation/changes/send_invitation_email.ex +defmodule Helpcenter.Accounts.Invitation.Changes.SendInvitationEmail do + @moduledoc """ + An Ash Resource Change that sends an invitation email to a user. + + This module handles sending an email to invite a user to join a team, + including a link to accept the invitation. It uses Oban for asynchronous + email delivery. + """ + + use Ash.Resource.Change + use HelpcenterWeb, :verified_routes + + @doc """ + Registers an after_action callback to send the invitation email after + the changeset is processed. + """ + @impl true + def change(changeset, _opts, _context) do + Ash.Changeset.after_action(changeset, &send_email/2) + end + + @doc """ + Implements the atomic change hook for Ash to ensure atomic operations. + """ + @impl true + def atomic(changeset, opts, context) do + {:ok, change(changeset, opts, context)} + end + + defp send_email(_changeset, invitation) do + with {:ok, email_data} <- build_email_data(invitation), + {:ok, _job} <- schedule_email(email_data) do + {:ok, invitation} + else + {:error, reason} -> + {:error, "Failed to send invitation email: #{inspect(reason)}"} + end + end + + defp build_email_data(%{email: email, token: token, team: team}) do + message = build_email_body(email, token, team) + + email_data = %{ + id: token, + params: %{ + html_message: message, + text_message: message, + subject: "You're Invited to Join #{team}", + from: nil, + to: email + } + } + + {:ok, email_data} + end + + defp build_email_body(email, token, team) do + accept_url = url(~p"/accounts/users/invitations/#{team}/#{token}/accept") + + """ +

Hello, #{email}!

+

You've been invited to join the #{team} team.

+

Please click the link below to accept the invitation:

+

Accept Invitation

+

If you believe this invitation was sent in error, please contact the team administrator.

+ """ + end + + defp schedule_email(email_data) do + email_data + |> Helpcenter.Workers.EmailSender.new() + |> Oban.insert() + end +end diff --git a/lib/helpcenter/accounts/invitation/changes/send_welcome_email.ex b/lib/helpcenter/accounts/invitation/changes/send_welcome_email.ex new file mode 100644 index 0000000..5b1feb9 --- /dev/null +++ b/lib/helpcenter/accounts/invitation/changes/send_welcome_email.ex @@ -0,0 +1,88 @@ +# lib/helpcenter/accounts/invitation/changes/send_welcome_email.ex +defmodule Helpcenter.Accounts.Invitation.Changes.SendWelcomeEmail do + @moduledoc """ + An Ash Resource Change that sends a welcome email to a user. + + This module handles sending a welcome email with a magic link for signing in + after a user accepts an invitation. It uses Oban for asynchronous email delivery. + """ + + use Ash.Resource.Change + use HelpcenterWeb, :verified_routes + + @doc """ + Registers an after_action callback to send the welcome email after + the changeset is processed. + """ + @impl true + def change(changeset, _opts, _context) do + Ash.Changeset.after_action(changeset, &send_email/2) + end + + @doc """ + Implements the atomic change hook for Ash to ensure atomic operations. + """ + @impl true + def atomic(changeset, opts, context) do + {:ok, change(changeset, opts, context)} + end + + @doc """ + Sends a welcome email with a magic link for signing in. + + Builds the email content with a sign-in link and schedules it via the + EmailSender worker. + + ## Parameters + - _changeset: The Ash changeset (unused, kept for hook compatibility) + - invitation: The invitation struct containing email, token, and team + + ## Returns + - {:ok, invitation} on successful email scheduling + - {:error, reason} if email scheduling fails + """ + def send_email(_changeset, invitation) do + with {:ok, email_data} <- build_email_data(invitation), + {:ok, _job} <- schedule_email(email_data) do + {:ok, invitation} + else + {:error, reason} -> + {:error, "Failed to send welcome email: #{inspect(reason)}"} + end + end + + defp build_email_data(%{email: email, token: token, team: team}) do + message = build_email_body(email, token, team) + + email_data = %{ + id: token, + params: %{ + html_message: message, + text_message: message, + subject: "Welcome to #{team}!", + from: nil, + to: email + } + } + + {:ok, email_data} + end + + defp build_email_body(email, token, team) do + sign_in_url = url(~p"/auth/user/magic_link/?token=#{token}") + + """ +

Hello, #{email}!

+

Welcome to the #{team} team!

+

Please click the link below to sign in and get started:

+

Sign In

+

If you have any issues, please contact the team administrator.

+ """ + end + + defp schedule_email(email_data) do + email_data + |> Helpcenter.Workers.EmailSender.new() + |> Oban.insert() + end +end diff --git a/lib/helpcenter/accounts/invitation/changes/set_invitation_attributes.ex b/lib/helpcenter/accounts/invitation/changes/set_invitation_attributes.ex new file mode 100644 index 0000000..7caa7ce --- /dev/null +++ b/lib/helpcenter/accounts/invitation/changes/set_invitation_attributes.ex @@ -0,0 +1,25 @@ +# lib/helpcenter/accounts/invitation/changes/set_invitation_attributes.ex +defmodule Helpcenter.Accounts.Invitation.Changes.SetInvitationAttributes do + use Ash.Resource.Change + + @impl true + def change(changeset, _opts, context) do + Ash.Changeset.before_transaction(changeset, &set_attributes(&1, context)) + end + + @impl true + def atomic(changeset, opts, context) do + {:ok, change(changeset, opts, context)} + end + + defp set_attributes(changeset, context) do + expires_at = DateTime.add(DateTime.utc_now(), 30, :day) + + changeset + |> Ash.Changeset.force_change_attribute(:expires_at, expires_at) + |> Ash.Changeset.force_change_attribute(:team, changeset.tenant) + |> Ash.Changeset.force_change_attribute(:token, Ash.UUIDv7.generate()) + |> Ash.Changeset.force_change_attribute(:team, context.actor.current_team) + |> Ash.Changeset.force_change_attribute(:inviter_user_id, context.actor.id) + end +end diff --git a/lib/helpcenter/accounts/invitation/preparations/for_current_team.ex b/lib/helpcenter/accounts/invitation/preparations/for_current_team.ex new file mode 100644 index 0000000..a4aba38 --- /dev/null +++ b/lib/helpcenter/accounts/invitation/preparations/for_current_team.ex @@ -0,0 +1,10 @@ +defmodule Helpcenter.Accounts.Invitation.Preparations.ForCurrentTeam do + use Ash.Resource.Preparation + + def prepare(query, _options, %{actor: nil}), do: query + + def prepare(query, _opts, context) do + %{current_team: team} = context.actor + Ash.Query.filter(query, team == ^team) + end +end diff --git a/lib/helpcenter/accounts/invitation/validations/ensure_pending_status.ex b/lib/helpcenter/accounts/invitation/validations/ensure_pending_status.ex new file mode 100644 index 0000000..870029f --- /dev/null +++ b/lib/helpcenter/accounts/invitation/validations/ensure_pending_status.ex @@ -0,0 +1,44 @@ +# lib/helpcenter/accounts/invitation/validations/ensure_pending_status.ex +defmodule Helpcenter.Accounts.Invitation.Validations.EnsurePendingStatus do + use Ash.Resource.Validation + + def validate(changeset, _opts, context) do + changeset + |> get_invitation(context) + |> validate_pending_status() + end + + @doc """ + Atomic function's return must be the same as the main behaviour callback function. + that's why we are not returning {:ok, validate(changeset, opts, context)} + like in a change {:ok, change(changeset, opts, context)} instead we + retruning :ok or {:error, ...} directly as per validate definition + """ + def atomic(changeset, opts, context) do + validate(changeset, opts, context) + end + + defp get_invitation(changeset, context) do + require Ash.Query + token = Ash.Changeset.get_attribute(changeset, :token) + + Helpcenter.Accounts.Invitation + |> Ash.Query.filter(token == ^token) + |> Ash.read_first(Ash.Context.to_opts(context)) + end + + # Validate that the invitation is still pending + defp validate_pending_status({:ok, %{status: :pending}}), do: :ok + + defp validate_pending_status({:ok, %{status: :accepted}}) do + {:error, field: :token, message: "This invitation has already been accepted."} + end + + defp validate_pending_status({:ok, %{status: :declined}}) do + {:error, field: :token, message: "This invitation has already been declined."} + end + + defp validate_pending_status(_others) do + {:error, field: :token, message: "This invitation token is invalid or has expired."} + end +end diff --git a/lib/helpcenter/accounts/invitation_test.exs b/lib/helpcenter/accounts/invitation_test.exs new file mode 100644 index 0000000..85b985b --- /dev/null +++ b/lib/helpcenter/accounts/invitation_test.exs @@ -0,0 +1,75 @@ +defmodule Helpcenter.Accounts.InvitationTest do + use HelpcenterWeb.ConnCase + require Ash.Query + + defp create_group!(actor) do + group_attrs = %{name: "Accountants", description: "Can manage billing in the system"} + + Ash.create!( + Helpcenter.Accounts.Group, + group_attrs, + actor: actor, + authorize?: false + ) + end + + describe "Invitations test" do + test "Team owner can invite a new user" do + actor = create_user() + group = create_group!(actor) + invite_attributes = %{email: "john@example.com", group_id: group.id} + + invitation = + Helpcenter.Accounts.Invitation + |> Ash.Changeset.for_create(:create, invite_attributes, actor: actor) + |> Ash.create!() + + assert invitation.status == :pending + assert invitation.email == invite_attributes.email + assert invitation.group_id == group.id + + # Accept invitation + accepted_invitation = + invitation + |> Ash.Changeset.for_update(:accept, %{}, actor: actor) + |> Ash.update!() + + assert accepted_invitation.status == :accepted + assert accepted_invitation.email == invite_attributes.email + assert accepted_invitation.group_id == group.id + + # Confirm that user cannot decline an already accepted invitation + {:error, %Ash.Error.Invalid{errors: errors}} = + accepted_invitation + |> Ash.Changeset.for_update(:decline, %{}, actor: actor) + |> Ash.update() + + error = List.first(errors) + assert error.field == :token + assert error.message == "This invitation has already been accepted." + end + + test "Invitation can be decline an invitation" do + actor = create_user() + group = create_group!(actor) + invite_attributes = %{email: "john@example.com", group_id: group.id} + + invitation = + Helpcenter.Accounts.Invitation + |> Ash.Changeset.for_create(:create, invite_attributes, actor: actor) + |> Ash.create!() + + assert invitation.status == :pending + assert invitation.email == invite_attributes.email + assert invitation.group_id == group.id + + # Decline invitation + declined_invitation = + invitation + |> Ash.Changeset.for_update(:decline, %{}, actor: actor) + |> Ash.update!() + + assert declined_invitation.status == :declined + end + end +end diff --git a/lib/helpcenter/accounts/team.ex b/lib/helpcenter/accounts/team.ex index 534be8a..d2a746c 100644 --- a/lib/helpcenter/accounts/team.ex +++ b/lib/helpcenter/accounts/team.ex @@ -31,6 +31,11 @@ defmodule Helpcenter.Accounts.Team do end end + code_interface do + # the action open can be omitted because it matches the function name + define :by_domain, args: [:domain], action: :by_domain + end + actions do default_accept [:name, :domain, :description, :owner_user_id] defaults [:read] @@ -40,12 +45,28 @@ defmodule Helpcenter.Accounts.Team do change Helpcenter.Accounts.Team.Changes.AssociateUserToTeam change Helpcenter.Accounts.Team.Changes.SetOwnerCurrentTeamAfterCreate end + + read :by_domain do + description "This action is used to read a team by its domain" + filter expr(domain == ^arg(:domain)) + end end attributes do uuid_v7_primary_key :id - attribute :name, :string, allow_nil?: false, public?: true - attribute :domain, :string, allow_nil?: false, public?: true + + attribute :name, :string do + allow_nil? false + public? true + description "Team or organisation name" + end + + attribute :domain, :string do + allow_nil? false + public? true + description "Domain name of the team or organisation" + end + attribute :description, :string, allow_nil?: true, public?: true timestamps() @@ -62,4 +83,10 @@ defmodule Helpcenter.Accounts.Team do destination_attribute_on_join_resource :user_id end end + + identities do + identity :unique_domain, [:domain] do + description "Identity to find a team by its domain" + end + end end diff --git a/lib/helpcenter/accounts/user.ex b/lib/helpcenter/accounts/user.ex index 35cca33..355ed04 100644 --- a/lib/helpcenter/accounts/user.ex +++ b/lib/helpcenter/accounts/user.ex @@ -17,6 +17,7 @@ defmodule Helpcenter.Accounts.User do monitor_fields [:email] confirm_on_create? true confirm_on_update? false + require_interaction? true auto_confirm_actions [:sign_in_with_magic_link, :reset_password_with_token] sender Helpcenter.Accounts.User.Senders.SendNewUserConfirmationEmail end @@ -34,7 +35,7 @@ defmodule Helpcenter.Accounts.User do magic_link do identity_field :email registration_enabled? true - + require_interaction? true sender Helpcenter.Accounts.User.Senders.SendMagicLinkEmail end @@ -56,6 +57,10 @@ defmodule Helpcenter.Accounts.User do repo Helpcenter.Repo end + code_interface do + define :get_by_email, args: [:email], action: :get_by_email + end + actions do default_accept [:email] defaults [:create, :read] diff --git a/lib/helpcenter/extensions/ash_parental/ash_parental.ex b/lib/helpcenter/extensions/ash_parental/ash_parental.ex new file mode 100644 index 0000000..0bef57c --- /dev/null +++ b/lib/helpcenter/extensions/ash_parental/ash_parental.ex @@ -0,0 +1,57 @@ +# lib/helpcenter/extensions/ash_parental/ash_parental.ex +defmodule Helpcenter.Extensions.AshParental do + @moduledoc """ + An Ash extension that adds parental relationships to a resource. + When added to a resource, it will automatically add: + - A `parent_id` attribute (of the same type as the resource's primary key) + - A `belongs_to :parent` relationship to the same resource + - A `has_many :children` relationship to the same resource + - A `count_of_children` aggregate to count the number of children + """ + + @transformers [ + Helpcenter.Extensions.AshParental.Transformers.AddParentIdAttribute, + + # Add relationships after the attribute and aggregate have been added + Helpcenter.Extensions.AshParental.Transformers.AddBelongsToParentRelationship, + Helpcenter.Extensions.AshParental.Transformers.AddHasManyChildrenRelationship, + + # Aggregates + Helpcenter.Extensions.AshParental.Transformers.AddChildrenCountAggregate, + + # Add the destroy children change last + Helpcenter.Extensions.AshParental.Transformers.AddDestroyWithChildrenChange + ] + + # Define the DSL section and its schema for configuration + # <-- This part is added + @section %Spark.Dsl.Section{ + name: :ash_parental, + describe: "Configurations for the AshParental extension", + examples: [ + """ + ash_parental do + children_relationship_name :sub_items + on_parent_delete_destroy_children true + end + """ + ], + schema: [ + children_relationship_name: [ + type: :atom, + doc: "The name of the children relationship to be added", + default: :children + ], + distroy_with_children?: [ + type: :boolean, + doc: "If true, deleting a parent will also delete its children", + default: false + ] + ] + } + + use Spark.Dsl.Extension, + transformers: @transformers, + # <-- Add the DSL section to the extension + sections: [@section] +end diff --git a/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs b/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs new file mode 100644 index 0000000..937567a --- /dev/null +++ b/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs @@ -0,0 +1,133 @@ +# lib/helpcenter/extensions/ash_parental/ash_parental_test.exs +defmodule Helpcenter.Extensions.AshParentalTest do + use ExUnit.Case + + # Define a simple Ash resource for testing purposes + defmodule Comment do + use Ash.Resource, + domain: Helpcenter.Extensions.AshParentalTest.Domain, + data_layer: Ash.DataLayer.Ets, + extensions: [Helpcenter.Extensions.AshParental] + + ets do + table :comments + end + + ash_parental do + distroy_with_children?(true) + end + + actions do + defaults [:create, :read, :update, :destroy] + end + + attributes do + uuid_primary_key :id + attribute :content, :string, allow_nil?: false + timestamps() + end + end + + defmodule Category do + use Ash.Resource, + domain: Helpcenter.Extensions.AshParentalTest.Domain, + data_layer: Ash.DataLayer.Ets, + extensions: [Helpcenter.Extensions.AshParental] + + ets do + table :categories + end + + ash_parental do + children_relationship_name(:subcategories) + end + + actions do + defaults [:create, :read, :update, :destroy] + end + + attributes do + uuid_primary_key :id + attribute :name, :string, allow_nil?: false + timestamps() + end + end + + # Define a domain to hold the resource for testing + defmodule Domain do + use Ash.Domain + + resources do + resource Helpcenter.Extensions.AshParentalTest.Comment + resource Helpcenter.Extensions.AshParentalTest.Category + end + end + + defp relationships(resource) do + Ash.Resource.Info.relationships(resource) |> Enum.map(& &1.name) + end + + alias Helpcenter.Extensions.AshParentalTest.Comment + + describe "AshParental" do + test "Adds parent_id to the resource" do + # Confirm that the parent_id attribute has been added + # to the reource's attributes after applying the extension + assert :parent_id in Ash.Resource.Info.attribute_names(Comment) + end + + test "Adds a belongs_to relationship to the resource" do + assert :parent in relationships(Comment) + end + + test "Adds a children relationships" do + assert :children in relationships(Comment) + end + + test "Adds children count aggregate" do + %{name: aggregate_name, kind: kind} = + Ash.Resource.Info.aggregates(Comment) + |> List.first() + + assert :count_of_children == aggregate_name + assert :count == kind + end + + test "Parents - Child and versa relationships records" do + parent = Ash.Seed.seed!(Comment, %{content: "parent"}) + child_1 = Ash.Seed.seed!(Comment, %{content: "child 1", parent_id: parent.id}) + Ash.Seed.seed!(Comment, %{content: "child 2", parent_id: parent.id}) + + parent_record = Ash.get!(Comment, parent.id, load: [:children, :count_of_children]) + + assert 2 == parent_record.count_of_children + assert Enum.count(parent_record.children) == parent_record.count_of_children + + child_1_record = Ash.get!(Comment, child_1.id, load: [:parent]) + + assert child_1_record.parent_id == parent.id + end + + test "Children relationship name is configurable" do + assert :subcategories in relationships(Category) + end + + test "Destroying a parent also destroys its children" do + parent = Ash.Seed.seed!(Comment, %{content: "parent"}) + Ash.Seed.seed!(Comment, %{content: "child 1", parent_id: parent.id}) + Ash.Seed.seed!(Comment, %{content: "child 2", parent_id: parent.id}) + + require Ash.Query + + # assert %{status: :success} = + Comment + |> Ash.get!(parent.id) + |> Ash.destroy() + + # Confirm that no children remain + refute Comment + |> Ash.Query.filter(parent_id == ^parent.id) + |> Ash.exists?() + end + end +end diff --git a/lib/helpcenter/extensions/ash_parental/changes/destroy_children.ex b/lib/helpcenter/extensions/ash_parental/changes/destroy_children.ex new file mode 100644 index 0000000..fc5b35c --- /dev/null +++ b/lib/helpcenter/extensions/ash_parental/changes/destroy_children.ex @@ -0,0 +1,22 @@ +defmodule Helpcenter.Extensions.AshParental.Changes.DestroyChildren do + use Ash.Resource.Change + + def change(changeset, _opts, context) do + Ash.Changeset.before_action(changeset, &delete_children(&1, context)) + end + + def atomic(changeset, opts, context) do + {:ok, change(changeset, opts, context)} + end + + defp delete_children(changeset, context) do + require Ash.Query + + %{status: :success} = + changeset.data.__struct__ + |> Ash.Query.filter(parent_id == ^changeset.data.id) + |> Ash.bulk_destroy(:destroy, %{}, Ash.Context.to_opts(context)) + + changeset + end +end diff --git a/lib/helpcenter/extensions/ash_parental/info.ex b/lib/helpcenter/extensions/ash_parental/info.ex new file mode 100644 index 0000000..37118ae --- /dev/null +++ b/lib/helpcenter/extensions/ash_parental/info.ex @@ -0,0 +1,4 @@ +# lib/helpcenter/extensions/ash_parental/info.ex +defmodule Helpcenter.Extensions.AshParental.Info do + use Spark.InfoGenerator, extension: Helpcenter.Extensions.AshParental, sections: [:ash_parental] +end diff --git a/lib/helpcenter/extensions/ash_parental/readme.md b/lib/helpcenter/extensions/ash_parental/readme.md new file mode 100644 index 0000000..b504c55 --- /dev/null +++ b/lib/helpcenter/extensions/ash_parental/readme.md @@ -0,0 +1,29 @@ +# Ash Parental Resource +Ash Parental is an extension that brings STI(Single Table Inheritance) capability to a resource + +## What is single table inheritance (STI)? + +It's a fancy name for a simple concept: Extending a resource (usually to add specific behavior), but referencing the same table. + +```ex +defmodule MyApp.Comment do + use Ash.Resource, extensions: [AshParental] + .... +end +``` + +The above should do the following: +1.Add `parent_id` attributes that is nullable +2.Add `parent` belongs to relationship +3.Add `children` has_many relationship +4.Add `children_count` aggregates + +Configurations + +```ex + +ash_parental do + children_relationship_name :subcategories + +end +``` diff --git a/lib/helpcenter/extensions/ash_parental/transformers/add_belongs_to_parent_relationship.ex b/lib/helpcenter/extensions/ash_parental/transformers/add_belongs_to_parent_relationship.ex new file mode 100644 index 0000000..4daf0c5 --- /dev/null +++ b/lib/helpcenter/extensions/ash_parental/transformers/add_belongs_to_parent_relationship.ex @@ -0,0 +1,36 @@ +# lib/helpcenter/extensions/ash_parental/transformers/add_belongs_to_parent_relationship.ex +defmodule Helpcenter.Extensions.AshParental.Transformers.AddBelongsToParentRelationship do + use Spark.Dsl.Transformer + + @doc """ + Ensure that this transformer runs after the AddParentIdAttribute transformer. This will prevent + errors where the relationship is added before the parent_id field exists. + """ + def after?(Helpcenter.Extensions.AshParental.Transformers.AddParentIdAttribute), do: true + def after?(_), do: false + + def transform(dsl_state) do + Ash.Resource.Builder.add_new_relationship( + dsl_state, + :belongs_to, + :parent, + get_current_resource_name(dsl_state), + source_attribute: :parent_id, + destination_attribute: get_primary_key_name(dsl_state) + ) + end + + # Get the current resource name + defp get_current_resource_name(dsl_state) do + Spark.Dsl.Transformer.get_persisted(dsl_state, :module) + end + + # Get the current primary key name + defp get_primary_key_name(dsl_state) do + dsl_state + |> Ash.Resource.Info.primary_key() + |> Enum.map(&Ash.Resource.Info.attribute(dsl_state, &1)) + |> List.first() + |> Map.get(:name) + end +end diff --git a/lib/helpcenter/extensions/ash_parental/transformers/add_children_count_aggregate.ex b/lib/helpcenter/extensions/ash_parental/transformers/add_children_count_aggregate.ex new file mode 100644 index 0000000..849ac3c --- /dev/null +++ b/lib/helpcenter/extensions/ash_parental/transformers/add_children_count_aggregate.ex @@ -0,0 +1,25 @@ +# lib/helpcenter/extensions/ash_parental/transformers/add_children_count_aggregate.ex +defmodule Helpcenter.Extensions.AshParental.Transformers.AddChildrenCountAggregate do + use Spark.Dsl.Transformer + + def after?(Helpcenter.Extensions.AshParental.Transformers.AddHasManyChildrenRelationship) do + true + end + + def after?(_), do: false + + def transform(dsl_state) do + Ash.Resource.Builder.add_new_aggregate( + dsl_state, + :count_of_children, + :count, + # <-- Use the configured children relationship name + [get_children_relationship_name(dsl_state)] + ) + end + + defp get_children_relationship_name(dsl_state) do + dsl_state + |> Helpcenter.Extensions.AshParental.Info.ash_parental_children_relationship_name!() + end +end diff --git a/lib/helpcenter/extensions/ash_parental/transformers/add_destroy_with_children_change.ex b/lib/helpcenter/extensions/ash_parental/transformers/add_destroy_with_children_change.ex new file mode 100644 index 0000000..8f9c8fe --- /dev/null +++ b/lib/helpcenter/extensions/ash_parental/transformers/add_destroy_with_children_change.ex @@ -0,0 +1,22 @@ +# # lib/helpcenter/extensions/ash_parental/transformers/add_delete_destroy_children_change.ex +defmodule Helpcenter.Extensions.AshParental.Transformers.AddDestroyWithChildrenChange do + use Spark.Dsl.Transformer + alias Helpcenter.Extensions.AshParental.Info + alias Helpcenter.Extensions.AshParental.Changes.DestroyChildren + alias Helpcenter.Extensions.AshParental.Transformers.AddHasManyChildrenRelationship + + @doc """ + Ensures this transformer runs after the AddHasManyChildrenRelationship transformer + """ + def after?(AddHasManyChildrenRelationship), do: true + def after?(_), do: false + + def transform(dsl_state) do + if Info.ash_parental_distroy_with_children?(dsl_state) do + # Specify only on: :destroy to avoid adding it to updates or create changes + Ash.Resource.Builder.add_change(dsl_state, DestroyChildren, on: :destroy) + else + {:ok, dsl_state} + end + end +end diff --git a/lib/helpcenter/extensions/ash_parental/transformers/add_has_many_children_relationship.ex b/lib/helpcenter/extensions/ash_parental/transformers/add_has_many_children_relationship.ex new file mode 100644 index 0000000..d59fcdd --- /dev/null +++ b/lib/helpcenter/extensions/ash_parental/transformers/add_has_many_children_relationship.ex @@ -0,0 +1,37 @@ +# lib/helpcenter/extensions/ash_parental/transformers/add_has_many_children_relationship.ex +defmodule Helpcenter.Extensions.AshParental.Transformers.AddHasManyChildrenRelationship do + use Spark.Dsl.Transformer + + def after?(Helpcenter.Extensions.AshParental.Transformers.AddParentIdAttribute), do: true + def after?(_), do: false + + def transform(dsl_state) do + Ash.Resource.Builder.add_new_relationship( + dsl_state, + :has_many, + # <-- Use the configured children relationship name + get_children_relationship_name(dsl_state), + get_current_resource_name(dsl_state), + source_attribute: get_primary_key_name(dsl_state), + destination_attribute: :parent_id + ) + end + + # <-- Retrieve the children relationship name from the resource configuration + defp get_children_relationship_name(dsl_state) do + dsl_state + |> Helpcenter.Extensions.AshParental.Info.ash_parental_children_relationship_name!() + end + + defp get_current_resource_name(dsl_state) do + Spark.Dsl.Transformer.get_persisted(dsl_state, :module) + end + + defp get_primary_key_name(dsl_state) do + dsl_state + |> Ash.Resource.Info.primary_key() + |> Enum.map(&Ash.Resource.Info.attribute(dsl_state, &1)) + |> List.first() + |> Map.get(:name) + end +end diff --git a/lib/helpcenter/extensions/ash_parental/transformers/add_parent_id_attribute.ex b/lib/helpcenter/extensions/ash_parental/transformers/add_parent_id_attribute.ex new file mode 100644 index 0000000..815b7ab --- /dev/null +++ b/lib/helpcenter/extensions/ash_parental/transformers/add_parent_id_attribute.ex @@ -0,0 +1,25 @@ +# lib/helpcenter/extensions/ash_parental/transformers/add_parent_id_attribute.ex +defmodule Helpcenter.Extensions.AshParental.Transformers.AddParentIdAttribute do + use Spark.Dsl.Transformer + + def transform(dsl_state) do + # Get the type of the primary key to use for the parent_id attribute + primary_key_type = get_primary_key_type(dsl_state) + + Ash.Resource.Builder.add_new_attribute( + dsl_state, + :parent_id, + primary_key_type, + allow_nil?: false + ) + end + + # Helper function to get the current resource primary key type + defp get_primary_key_type(dsl_state) do + dsl_state + |> Ash.Resource.Info.primary_key() + |> Enum.map(&Ash.Resource.Info.attribute(dsl_state, &1)) + |> List.first() + |> Map.get(:type) + end +end diff --git a/lib/helpcenter/knowledge_base/article.ex b/lib/helpcenter/knowledge_base/article.ex index 407abc2..61f3c43 100644 --- a/lib/helpcenter/knowledge_base/article.ex +++ b/lib/helpcenter/knowledge_base/article.ex @@ -111,7 +111,6 @@ defmodule Helpcenter.KnowledgeBase.Article do relationships do belongs_to :category, Helpcenter.KnowledgeBase.Category do source_attribute :category_id - # category_id can be null when there is no related category allow_nil? true end diff --git a/lib/helpcenter/knowledge_base/category.ex b/lib/helpcenter/knowledge_base/category.ex index 061e6ee..075d3ba 100644 --- a/lib/helpcenter/knowledge_base/category.ex +++ b/lib/helpcenter/knowledge_base/category.ex @@ -4,13 +4,10 @@ defmodule Helpcenter.KnowledgeBase.Category do domain: Helpcenter.KnowledgeBase, data_layer: AshPostgres.DataLayer, notifiers: Ash.Notifier.PubSub, - # Tell Ash that this resource require authorization authorizers: Ash.Policy.Authorizer postgres do - # <-- Tell Ash that this resource data is stored in a table named "categories" table "categories" - # <-- Tell Ash that this resource access data storage via Helpcenter.Repo repo Helpcenter.Repo end @@ -74,7 +71,6 @@ defmodule Helpcenter.KnowledgeBase.Category do changes do change Helpcenter.Changes.Slugify - # Automatically set tenant based on the user/ actor change Helpcenter.Changes.SetTenant end @@ -90,6 +86,8 @@ defmodule Helpcenter.KnowledgeBase.Category do attribute :slug, :string attribute :description, :string, allow_nil?: true + attribute :parent_id, :uuid, allow_nil?: true + timestamps() end diff --git a/lib/helpcenter/knowledge_base/comment.ex b/lib/helpcenter/knowledge_base/comment.ex index d42da55..8f8da73 100644 --- a/lib/helpcenter/knowledge_base/comment.ex +++ b/lib/helpcenter/knowledge_base/comment.ex @@ -1,7 +1,8 @@ defmodule Helpcenter.KnowledgeBase.Comment do use Ash.Resource, domain: Helpcenter.KnowledgeBase, - data_layer: AshPostgres.DataLayer + data_layer: AshPostgres.DataLayer, + extensions: [Helpcenter.Extensions.AshParental] postgres do table "comments" @@ -13,6 +14,10 @@ defmodule Helpcenter.KnowledgeBase.Comment do defaults [:create, :read, :update, :destroy] end + ash_parental do + distroy_with_children?(true) + end + preparations do prepare Helpcenter.Preparations.SetTenant end diff --git a/lib/helpcenter/preparations/set_tenant.ex b/lib/helpcenter/preparations/set_tenant.ex index 9682d94..b748bdc 100644 --- a/lib/helpcenter/preparations/set_tenant.ex +++ b/lib/helpcenter/preparations/set_tenant.ex @@ -11,11 +11,12 @@ defmodule Helpcenter.Preparations.SetTenant do 2. If tenant is not provided, but actor is provided, the use current_team user 3. If none of the above conditions are met, ignore and contineu """ - def prepare(query, _opts, %{tenant: nil, actor: nil} = _context), do: query - def prepare(query, _opts, %{tenant: nil, actor: actor} = _context) do - Ash.Query.set_tenant(query, actor.current_team) + def prepare(query, _opts, %{actor: nil} = _context) do + query end - def prepare(query, _opts, _context), do: query + def prepare(query, _opts, context) do + Ash.Query.set_tenant(query, context.actor.current_team) + end end diff --git a/lib/helpcenter/workers/email_sender.ex b/lib/helpcenter/workers/email_sender.ex new file mode 100644 index 0000000..287e159 --- /dev/null +++ b/lib/helpcenter/workers/email_sender.ex @@ -0,0 +1,20 @@ +# lib/helpcenter/workers/email_sender.ex +defmodule Helpcenter.Workers.EmailSender do + use Oban.Worker, queue: :default + import Swoosh.Email + + @impl Oban.Worker + def perform(job) do + params = job.args["params"] + + new() + |> from({"Helpcenter", "no-reply@helpcenter.com"}) + |> to(params["to"] |> to_string()) + |> subject(params["subject"]) + |> html_body(params["body"]) + |> text_body(params["text"]) + |> Helpcenter.Mailer.deliver!() + + :ok + end +end diff --git a/lib/helpcenter_web/components/layouts/app.html.heex b/lib/helpcenter_web/components/layouts/app.html.heex index 3ac7977..b9679c5 100644 --- a/lib/helpcenter_web/components/layouts/app.html.heex +++ b/lib/helpcenter_web/components/layouts/app.html.heex @@ -3,7 +3,6 @@

Helpcenter @@ -20,6 +19,22 @@

<%!-- Only show this div if the user is logged in --%>
+ <.link + id="users" + navigate={~p"/accounts/users"} + class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700 hover:underline" + > + <.icon name="hero-users" class="h-4 w-4" /> + {gettext("Users")} + + <.link + id="user-invitations" + navigate={~p"/accounts/users/invitations"} + class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700 hover:underline" + > + <.icon name="hero-message" class="h-4 w-4" /> + {gettext("Invitations")} + <.link id="categories" navigate={~p"/categories"} diff --git a/lib/helpcenter_web/controllers/auth_controller.ex b/lib/helpcenter_web/controllers/auth_controller.ex index 50939e5..a7fcd77 100644 --- a/lib/helpcenter_web/controllers/auth_controller.ex +++ b/lib/helpcenter_web/controllers/auth_controller.ex @@ -48,7 +48,7 @@ defmodule HelpcenterWeb.AuthController do return_to = get_session(conn, :return_to) || ~p"/" conn - |> clear_session() + |> clear_session(:helpcenter) |> put_flash(:info, "You are now signed out") |> redirect(to: return_to) end diff --git a/lib/helpcenter_web/controllers/team_invitation_acceptance_controller.ex b/lib/helpcenter_web/controllers/team_invitation_acceptance_controller.ex new file mode 100644 index 0000000..6a64717 --- /dev/null +++ b/lib/helpcenter_web/controllers/team_invitation_acceptance_controller.ex @@ -0,0 +1,87 @@ +# lib/helpcenter_web/controllers/team_invitation_acceptance_controller.ex +defmodule HelpcenterWeb.TeamInvitationAcceptanceController do + use HelpcenterWeb, :controller + + @doc """ + Accepts a team invitation using a tenant and token, updating the invitation status. + Redirects to the homepage with appropriate flash messages based on the outcome. + """ + def accept(conn, %{"tenant" => tenant, "token" => token}) do + # 1. Check if the invitation exists and is valid + # 2. If valid, accept inviation(creates account behind the scenes) + # 3. Redirect to homepage with success message + with {:ok, invitation} <- fetch_invitation(tenant, token), + {:ok, _updated_invitation} <- accept_invitation(invitation, tenant) do + conn + |> put_flash(:info, gettext("Invitation accepted")) + |> redirect(to: ~p"/") + else + {:error, error} -> + conn + |> put_flash(:error, format_error_message(error)) + |> redirect(to: ~p"/") + end + end + + def reject(conn, %{"tenant" => tenant, "token" => token}) do + # 1. Check if the invitation exists and is valid + # 2. If valid, reject inviation + # 3. Redirect to homepage with success message + + with {:ok, invitation} <- fetch_invitation(tenant, token), + {:ok, _updated_invitation} <- reject_invitation(invitation, tenant) do + conn + |> put_flash(:info, gettext("Invitation rejected")) + |> redirect(to: ~p"/") + else + {:error, error} -> + conn + |> put_flash(:error, format_error_message(error)) + |> redirect(to: ~p"/") + end + end + + defp fetch_invitation(tenant, token) do + require Ash.Query + + options = [tenant: tenant, authorize?: false] + + Helpcenter.Accounts.Invitation + |> Ash.Query.filter(token == ^token) + |> Ash.read_first(options) + |> case do + {:ok, nil} -> {:error, :invitation_not_found} + other -> other + end + end + + defp accept_invitation(invitation, tenant) do + options = [tenant: tenant, authorize?: false] + + invitation + |> Ash.Changeset.for_update(:accept, %{}, options) + |> Ash.update() + end + + defp reject_invitation(invitation, tenant) do + options = [tenant: tenant, authorize?: false] + + invitation + |> Ash.Changeset.for_update(:accept, %{}, options) + |> Ash.update() + end + + defp format_error_message(:invitation_not_found) do + gettext("Invitation not found") + end + + defp format_error_message(%Ash.Error.Invalid{errors: errors}) do + errors + |> Enum.map(& &1.message) + |> Enum.join(", ") + end + + defp format_error_message(_other) do + gettext("Unable to accept invitation") + end +end diff --git a/lib/helpcenter_web/controllers/team_invitation_acceptance_controller_test.exs b/lib/helpcenter_web/controllers/team_invitation_acceptance_controller_test.exs new file mode 100644 index 0000000..ea751e5 --- /dev/null +++ b/lib/helpcenter_web/controllers/team_invitation_acceptance_controller_test.exs @@ -0,0 +1,47 @@ +# lib/helpcenter_web/controllers/team_invitation_acceptance_controller_test.exs +defmodule HelpcenterWeb.TeamInvitationAcceptanceControllerTEst do + use HelpcenterWeb.ConnCase + + defp create_group!(actor) do + group_attrs = %{name: "Accountants", description: "Can manage billing in the system"} + + Ash.create!( + Helpcenter.Accounts.Group, + group_attrs, + actor: actor, + authorize?: false + ) + end + + describe "Team invitation acceptance controller" do + test "User can accept an invitation", %{conn: conn} do + # 1. Create auser + # 2. Add user to the team + # 3. Get the invitation token and send it in the URL + # 4. Confirm the notifiaction flash + # 5. Ensure that the user has been added to the team + actor = create_user() + group = create_group!(actor) + invite_attributes = %{email: "john@example.com", group_id: group.id} + + invitation = + Helpcenter.Accounts.Invitation + |> Ash.Changeset.for_create(:create, invite_attributes, actor: actor) + |> Ash.create!() + + conn = + conn + |> get(~p"/accounts/users/invitations/#{invitation.team}/#{invitation.token}/accept") + + assert redirected_to(conn) == ~p"/" + + # Confirm the invitation has been accepted + require Ash.Query + + assert Helpcenter.Accounts.Invitation + |> Ash.Query.filter(token == ^invitation.token) + |> Ash.Query.filter(status == :accepted) + |> Ash.exists?(actor: actor) + end + end +end diff --git a/lib/helpcenter_web/live/accounts/users/user_invitations_live.ex b/lib/helpcenter_web/live/accounts/users/user_invitations_live.ex new file mode 100644 index 0000000..18b21d1 --- /dev/null +++ b/lib/helpcenter_web/live/accounts/users/user_invitations_live.ex @@ -0,0 +1,33 @@ +# lib/helpcenter_web/live/accounts/users/user_invitations_live.ex +defmodule HelpcenterWeb.Accounts.Users.UserInvitationsLive do + use HelpcenterWeb, :live_view + + @impl true + def render(assigns) do + ~H""" + + + + <:col :let={row} label="Email" field="email" filter sort> + {Phoenix.Naming.humanize(row.email)} + + <:col :let={row} label="Status" field="status" filter sort> + {Phoenix.Naming.humanize(row.status)} + + <:col :let={row} label="Team">{Phoenix.Naming.humanize(row.team)} + + """ + end + + @impl true + def mount(_params, _sessions, socket) do + socket + |> assign(:page_title, gettext("User Invitations")) + |> ok() + end +end diff --git a/lib/helpcenter_web/live/accounts/users/user_invitations_live/invite_new_user_form.ex b/lib/helpcenter_web/live/accounts/users/user_invitations_live/invite_new_user_form.ex new file mode 100644 index 0000000..b208150 --- /dev/null +++ b/lib/helpcenter_web/live/accounts/users/user_invitations_live/invite_new_user_form.ex @@ -0,0 +1,124 @@ +# lib/helpcenter_web/live/accounts/users/user_invitations_live/invite_new_user_form.ex +defmodule HelpcenterWeb.Accounts.Users.UserInvitationsLive.InviteNewUserForm do + @moduledoc """ + LiveComponent that renders a form to invite a new user to the current user's team. + The form includes fields for the user's email and the access group they should be assigned to. + When the form is submitted, an invitation is created in the system, and an invitation email + is sent to the provided email address. + + This component is designed to be used within a modal dialog, which can be triggered by a button + elsewhere in the application. The modal button is this compoent + """ + use HelpcenterWeb, :live_component + + @doc """ + This a wrapper used to access this component like a static component + in the template. + """ + attr :actor, Helpcenter.Accounts.User, required: true + + def form(assigns) do + ~H""" + <.live_component id={Ash.UUIDv7.generate()} actor={@actor} module={__MODULE__} /> + """ + end + + attr :id, :string, default: Ash.UUIDv7.generate() + attr :actor, Helpcenter.Accounts.User, required: true + + def render(assigns) do + ~H""" +
+ <.button phx-click={show_modal("invite-user-modal")} id="invite-user-modal-button"> + {gettext("Invite a user")} + + <.modal id="invite-user-modal"> +
+

+ {gettext("Invite a new member to your team")} +

+

+ {gettext( + "Invite a new user to your team by entering their email address below. They will receive an invitation email with instructions on how to join your team." + )} +

+
+ + <.simple_form for={@form} phx-change="validate" phx-submit="save" phx-target={@myself}> + <.input field={@form[:email]} type="email" label="New user Email" /> + <.input + field={@form[:group_id]} + type="select" + options={@access_groups} + label={gettext("Select Access Group")} + /> +
+ <.button> + {gettext("Invite User")} <.icon name="hero-paper-airplane-solid" class="ml-2 h-5 w-5" /> + +
+ + +
+ """ + end + + @impl true + def update(assigns, socket) do + socket + |> assign(assigns) + |> assign_access_groups() + |> assign_form() + |> ok() + end + + @impl true + def handle_event("validate", %{"form" => params}, socket) do + form = AshPhoenix.Form.validate(socket.assigns.form, params) + + socket + |> assign(:form, form) + |> noreply() + end + + @impl true + def handle_event("save", %{"form" => params}, socket) do + case AshPhoenix.Form.submit(socket.assigns.form, params: params) do + {:ok, _invitation} -> + socket + |> put_flash(:info, "Invitation sent successfully") + |> push_event("js-exec", %{to: "#invite-user-modal", attr: "data-cancel"}) + |> redirect(to: ~p"/accounts/users/invitations") + |> noreply() + + {:error, form} -> + socket + |> assign(:form, form) + |> noreply() + end + end + + defp assign_form(socket) do + assign(socket, :form, get_form(socket.assigns)) + end + + # Prevents the form from being re-created on every update + defp get_form(%{form: form}) when is_map(form), do: form + + defp get_form(%{actor: actor}) do + Helpcenter.Accounts.Invitation + |> AshPhoenix.Form.for_create(:create, actor: actor) + |> to_form() + end + + defp assign_access_groups(socket) do + %{actor: actor} = socket.assigns + + groups = + Helpcenter.Accounts.Group + |> Ash.read!(tenant: actor.current_team, authorize?: false) + |> Enum.map(&{&1.name, &1.id}) + + assign(socket, :access_groups, groups) + end +end diff --git a/lib/helpcenter_web/live/accounts/users/user_invitations_live/invite_new_user_form_test.exs b/lib/helpcenter_web/live/accounts/users/user_invitations_live/invite_new_user_form_test.exs new file mode 100644 index 0000000..e48070e --- /dev/null +++ b/lib/helpcenter_web/live/accounts/users/user_invitations_live/invite_new_user_form_test.exs @@ -0,0 +1,57 @@ +defmodule HelpcenterWeb.Accounts.Users.UserInvitationsLive.InviteNewUserFormTest do + use HelpcenterWeb.ConnCase + alias HelpcenterWeb.Accounts.Users.UserInvitationsLive.InviteNewUserForm + + defp create_group!(actor) do + group_attrs = %{name: "Accountants", description: "Can manage billing in the system"} + + Ash.create!( + Helpcenter.Accounts.Group, + group_attrs, + actor: actor, + authorize?: false + ) + end + + describe "Invite New User Form" do + test "Invite user form renders successfully" do + assigns = %{id: Ash.UUIDv7.generate(), actor: create_user()} + html = render_component(InviteNewUserForm, assigns) + + assert html =~ "email" + assert html =~ "group_id" + assert html =~ "Invite User" + assert html =~ "Invite a new member to your team" + end + + test "New user can be invited successfully", %{conn: conn} do + user = create_user() + group = create_group!(user) + + {:ok, view, html} = + conn + |> login(user) + |> live("/accounts/users/invitations") + + # Confirm invite user button is present + assert html =~ "Invite a user" + assert has_element?(view, "#invite-user-modal-button") + + # Confirm submitting the form works + params = %{email: "tester@example.com", group_id: group.id} + + view + |> form("#invite-user-modal form", form: params) + |> render_submit() + + # Confirm that the invitation was created in the database + require Ash.Query + + assert Helpcenter.Accounts.Invitation + |> Ash.Query.filter(email == ^params.email) + |> Ash.Query.filter(group_id == ^group.id) + |> Ash.Query.filter(status == :pending) + |> Ash.exists?(actor: user, authorize?: false) + end + end +end diff --git a/lib/helpcenter_web/live/accounts/users/user_invitations_live_test.exs b/lib/helpcenter_web/live/accounts/users/user_invitations_live_test.exs new file mode 100644 index 0000000..ea509c8 --- /dev/null +++ b/lib/helpcenter_web/live/accounts/users/user_invitations_live_test.exs @@ -0,0 +1,17 @@ +defmodule HelpcenterWeb.Accounts.Users.UserInvitationsLiveTest do + use HelpcenterWeb.ConnCase + + describe "User Invitations Live test" do + test "User can list and accept invitation", %{conn: conn} do + user = create_user() + + conn = + conn + |> login(user) + |> get(~p"/accounts/users/invitations") + + assert html_response(conn, 200) =~ "Email" + assert html_response(conn, 200) =~ "Team" + end + end +end diff --git a/lib/helpcenter_web/live/accounts/users/users_live.ex b/lib/helpcenter_web/live/accounts/users/users_live.ex new file mode 100644 index 0000000..581ec85 --- /dev/null +++ b/lib/helpcenter_web/live/accounts/users/users_live.ex @@ -0,0 +1,32 @@ +# lib/helpcenter_web/live/accounts/users/users_live.ex +defmodule HelpcenterWeb.Accounts.Users.UsersLive do + use HelpcenterWeb, :live_view + + @impl true + def render(assigns) do + ~H""" + + + <%!-- User list --%> + + <:col :let={row} label="Email" field="email" filter sort>{row.email} + <:col :let={row} label="Team">{Phoenix.Naming.humanize(row.current_team)} + + """ + end + + @impl true + def mount(_params, _session, socket) do + socket + |> assign(:page_title, "Users") + |> ok() + end + + defp get_query(current_user) do + require Ash.Query + + Helpcenter.Accounts.User + |> Ash.Query.filter(teams.domain == ^current_user.current_team) + |> Ash.Query.for_read(:read, %{}, authorize?: false) + end +end diff --git a/lib/helpcenter_web/live/accounts/users/users_live_test.exs b/lib/helpcenter_web/live/accounts/users/users_live_test.exs new file mode 100644 index 0000000..28f6c52 --- /dev/null +++ b/lib/helpcenter_web/live/accounts/users/users_live_test.exs @@ -0,0 +1,19 @@ +# lib/helpcenter_web/live/accounts/users/users_live_test.exs +defmodule HelpcenterWeb.Accounts.Users.UsersLiveTest do + use HelpcenterWeb.ConnCase + + describe "UsersLive" do + test "renders the users page", %{conn: conn} do + user = create_user() + + conn = + conn + |> login(user) + |> get(~p"/accounts/users") + + assert html_response(conn, 200) =~ "Users" + assert html_response(conn, 200) =~ "Email" + assert html_response(conn, 200) =~ "Team" + end + end +end diff --git a/lib/helpcenter_web/router.ex b/lib/helpcenter_web/router.ex index 7be5a1b..c671f8a 100644 --- a/lib/helpcenter_web/router.ex +++ b/lib/helpcenter_web/router.ex @@ -37,6 +37,8 @@ defmodule HelpcenterWeb.Router do # If an authenticated user must *not* be present: # on_mount {HelpcenterWeb.LiveUserAuth, :live_no_user} + live "/grok", GrokLayoutLive + scope "/categories" do live "/", CategoriesLive live "/create", CreateCategoryLive @@ -44,9 +46,16 @@ defmodule HelpcenterWeb.Router do end # lib/helpcenter_web/router.ex - scope "/accounts/groups", Accounts.Groups do - live "/", GroupsLive - live "/:group_id/permissions", GroupPermissionsLive + scope "/accounts", Accounts do + scope "/users", Users do + live "/", UsersLive + live "/invitations", UserInvitationsLive + end + + scope "/groups", Groups do + live "/", GroupsLive + live "/:group_id/permissions", GroupPermissionsLive + end end end end @@ -79,6 +88,14 @@ defmodule HelpcenterWeb.Router do pipe_through :browser get "/", PageController, :home + + get "/accounts/users/invitations/:tenant/:token/accept", + TeamInvitationAcceptanceController, + :accept + + get "/accounts/users/invitations/:tenant/:token/reject", + TeamInvitationAcceptanceController, + :reject end # Other scopes may use custom stacks. diff --git a/lib/test_helper.exs b/lib/test_helper.exs new file mode 100644 index 0000000..92102e8 --- /dev/null +++ b/lib/test_helper.exs @@ -0,0 +1,2 @@ +ExUnit.start() +Ecto.Adapters.SQL.Sandbox.mode(Helpcenter.Repo, :manual) diff --git a/mix.exs b/mix.exs index 1ddd017..1733be1 100644 --- a/mix.exs +++ b/mix.exs @@ -1,3 +1,4 @@ +# mix.exs defmodule Helpcenter.MixProject do use Mix.Project @@ -9,6 +10,8 @@ defmodule Helpcenter.MixProject do elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, consolidate_protocols: Mix.env() != :dev, + # Include "lib" for integration tests + test_paths: ["test", "lib"], aliases: aliases(), deps: deps() ] @@ -33,6 +36,7 @@ defmodule Helpcenter.MixProject do # Type `mix help deps` for examples and options. defp deps do [ + {:cinder, "~> 0.5"}, {:gen_smtp, "~> 1.0"}, {:oban, "~> 2.0"}, {:ash_oban, "~> 0.4"}, @@ -40,7 +44,6 @@ defmodule Helpcenter.MixProject do {:ash_authentication, "~> 4.0"}, {:ash_authentication_phoenix, "~> 2.0"}, {:picosat_elixir, "~> 0.2"}, - {:mix_test_watch, "~> 1.0"}, {:distillery, "~> 2.0"}, {:edeliver, "~> 1.0"}, {:ash_phoenix, "~> 2.0"}, diff --git a/mix.lock b/mix.lock index 63d2fa2..5eb67b5 100644 --- a/mix.lock +++ b/mix.lock @@ -1,81 +1,82 @@ %{ "artificery": {:hex, :artificery, "0.4.3", "0bc4260f988dcb9dda4b23f9fc3c6c8b99a6220a331534fdf5bf2fd0d4333b02", [:mix], [], "hexpm", "12e95333a30e20884e937abdbefa3e7f5e05609c2ba8cf37b33f000b9ffc0504"}, - "ash": {:hex, :ash, "3.4.71", "ce8fa3c38bb59d067647bdc87aa9198335fdeeab36660c869b72c47339fc9d69", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.24 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f255da731b5b3ec4d916b5282faecbbbe9beb64a3641b4a45ac91160ffea3cc9"}, - "ash_authentication": {:hex, :ash_authentication, "4.6.0", "f466524b89166b76ec9847dc89283085119420d20bccaca6b52724081cb63911", [:mix], [{:ash, ">= 3.4.29 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, "~> 2.0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "~> 0.2.13", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "6c369d50fc8c702403ceedc329552ac631e3a83e5e0677b4901d1b77bbdbdb30"}, - "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.5.1", "aa11c8d58f26cd85e1de331d7e4423af2cbdfa9c35f1b5ec2c115a6badeb30d6", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.1", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "45980793197482cbcb3b76a5e60c87901ebc35f55bcc5c3399ec4cb44c605c88"}, + "ash": {:hex, :ash, "3.5.25", "99f7139e98b745a64312ae80e2420589205b2fec1799f00fc58da771d2c63373", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.4 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.2.65 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d45844ea30062b796d4adcad75b8d91e21081ac0f1bb6627d1a2663ca5ecf258"}, + "ash_authentication": {:hex, :ash_authentication, "4.9.5", "9a72fef7dc6912ef46bde34dc582eaa83c12e289c68b69456e8870857d122c85", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, ">= 3.4.29 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "~> 0.2.13", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "80e83db132c9b97fac739805f634f650cf8a30a904d6bb055be82c42b0506d29"}, + "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.10.2", "474b450ca85e4773d0eafd5c1c9dbcfdfc6e95f496aa049d3c20d4cb701829d9", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, ">= 4.9.1 and < 5.0.0-0", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "d122d97b5269d8f2acba749312ea9c6574c03b647cac9ff501cecaf7f0af7592"}, "ash_oban": {:hex, :ash_oban, "0.4.9", "91873f1e2c9d462554ec4902e1a90378f4351277dd81f4371c0984aed4ee300e", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:oban, "~> 2.15", [hex: :oban, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.18", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "f2d2ad468e0983f7a330f1511530d244209389a28e10bd0a05a0e30c7da6eae1"}, - "ash_phoenix": {:hex, :ash_phoenix, "2.1.23", "75c5500142d44c07431fcf7473784e6eed8d32777b68616de30b2ee7c3909110", [:mix], [{:ash, ">= 3.4.31 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, ">= 0.4.3 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "2d640dbd57020102d1a8997ab4b42035418696ebacfa5f8cbb94466e67deb8cf"}, - "ash_postgres": {:hex, :ash_postgres, "2.5.12", "cca37fb0a72114ea899f9a80cf7385b1826872263c75a7735f01898f4ff68a23", [:mix], [{:ash, ">= 3.4.69 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.2.62 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, ">= 3.12.1 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "b69a116b3d5b57fe868da914e1ed15286c46a0865b45f14950a38a6af06a915f"}, - "ash_sql": {:hex, :ash_sql, "0.2.62", "fcf1dde5a453cb024799bd43ab25aee3a7cc4ce7a48f1456310a65aec9e7ea7a", [:mix], [{:ash, ">= 3.4.65 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "df8c72b9b1c7b2c3147334eb63e819bc8d15288e1c6f0ddcd7691530db272ce0"}, + "ash_phoenix": {:hex, :ash_phoenix, "2.3.9", "684645f02725ca71625fcade6a4cc7c3a881a150762cdc532d03a32bde5a366d", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "462ed487e62aa7de14d587c3fed64c9a3971e4132a9d0121033754c444c1400e"}, + "ash_postgres": {:hex, :ash_postgres, "2.6.9", "8312bbe1ec463036841f08861196595ea233eadc1cd4c8097a1701ff7b0e95ed", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.2.72 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "1eb0c258d7dbe594312b37697097745ebb396162f38cddde282e011c26eb834c"}, + "ash_sql": {:hex, :ash_sql, "0.2.84", "1187555609f4773aacb5cccdca82a78c2b3f7390e78b400a8f03c91b2e7cd82f", [:mix], [{:ash, ">= 3.5.25 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "5e6a4d3070e60a0653c572527276a8c034b9458e37b1aca8868b17fcf0a1d1c0"}, "assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"}, - "bandit": {:hex, :bandit, "1.6.9", "cf4653d0490941629a4475381eda3b8d4d2653471a9efe0147b2195bef40ece5", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "67ab91338f308da9fb10d5afde35899e15af653adf31d682dd3a0e7c1d34db23"}, - "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.2.1", "e361261a0401d82dadc1ab7b969f91d250bf7577283e933fe8c5b72f8f5b3c46", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "81170177d5c2e280d12141a0b9d9e299bf731535e2d959982bdcd4cfe3c82865"}, - "castore": {:hex, :castore, "1.0.12", "053f0e32700cbec356280c0e835df425a3be4bc1e0627b714330ad9d0f05497f", [:mix], [], "hexpm", "3dca286b2186055ba0c9449b4e95b97bf1b57b47c1f2644555879e659960c224"}, + "bandit": {:hex, :bandit, "1.7.0", "d1564f30553c97d3e25f9623144bb8df11f3787a26733f00b21699a128105c0c", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "3e2f7a98c7a11f48d9d8c037f7177cd39778e74d55c7af06fe6227c742a8168a"}, + "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, + "castore": {:hex, :castore, "1.0.14", "4582dd7d630b48cf5e1ca8d3d42494db51e406b7ba704e81fbd401866366896a", [:mix], [], "hexpm", "7bc1b65249d31701393edaaac18ec8398d8974d52c647b7904d01b964137b9f4"}, + "cinder": {:hex, :cinder, "0.5.5", "745c7085694114777bf20a811d8213f103fab06388c9b8fa378479af238aaa36", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "c6dd26cb2e770b5742523defb15e1f8fec0e5261be9248e531f7f59f1e67ba06"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, - "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, + "db_connection": {:hex, :db_connection, "2.8.0", "64fd82cfa6d8e25ec6660cea73e92a4cbc6a18b31343910427b702838c4b33b2", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "008399dae5eee1bf5caa6e86d204dcb44242c82b1ed5e22c881f2c34da201b15"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "distillery": {:hex, :distillery, "2.1.1", "f9332afc2eec8a1a2b86f22429e068ef35f84a93ea1718265e740d90dd367814", [:mix], [{:artificery, "~> 0.2", [hex: :artificery, repo: "hexpm", optional: false]}], "hexpm", "bbc7008b0161a6f130d8d903b5b3232351fccc9c31a991f8fcbf2a12ace22995"}, "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, - "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, - "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"}, + "ecto": {:hex, :ecto, "3.13.2", "7d0c0863f3fc8d71d17fc3ad3b9424beae13f02712ad84191a826c7169484f01", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "669d9291370513ff56e7b7e7081b7af3283d02e046cf3d403053c557894a0b3e"}, + "ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"}, "edeliver": {:hex, :edeliver, "1.9.2", "78caa5a261c265dc9c17226e890b330165612145da312012dbe35d0e8631291b", [:mix], [{:distillery, "~> 2.1.0", [hex: :distillery, repo: "hexpm", optional: true]}], "hexpm", "cf6600ec1326d8aa224410e2ee02133f88e4f71dedb70db7b98b6acaea7e771f"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, - "esbuild": {:hex, :esbuild, "0.9.0", "f043eeaca4932ca8e16e5429aebd90f7766f31ac160a25cbd9befe84f2bc068f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "b415027f71d5ab57ef2be844b2a10d0c1b5a492d431727f43937adce22ba45ae"}, + "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, "ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"}, "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, - "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"}, - "floki": {:hex, :floki, "0.37.1", "d7aaee758c8a5b4a7495799a4260754fec5530d95b9c383c03b27359dea117cf", [:mix], [], "hexpm", "673d040cb594d31318d514590246b6dd587ed341d3b67e17c1c0eb8ce7ca6f04"}, + "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [: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", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, + "floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"}, "gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"}, "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]}, - "hpax": {:hex, :hpax, "1.0.2", "762df951b0c399ff67cc57c3995ec3cf46d696e41f0bba17da0518d94acd4aac", [:mix], [], "hexpm", "2f09b4c1074e0abd846747329eaa26d535be0eb3d189fa69d812bfb8bfefd32f"}, - "igniter": {:hex, :igniter, "0.5.38", "436a6414abc9245e539d6c92a6f4854f62270fbf6547c4acf1e5a65c0e4f4d4b", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:inflex, "~> 2.0", [hex: :inflex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "e5f1474a2a7ad186f3b71074d9d1ef25d306634e12af4ea01beae06ba958491d"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "igniter": {:hex, :igniter, "0.6.11", "bd7269949df4e668fba4219aad32fc922e48f5197e2e27e106cc44d972e50649", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "8dc87860a66597dd93abfaf640a3ce747bfcdf53d7dcbb2ec51f5edfb741307d"}, "inflex": {:hex, :inflex, "2.1.0", "a365cf0821a9dacb65067abd95008ca1b0bb7dcdd85ae59965deef2aa062924c", [:mix], [], "hexpm", "14c17d05db4ee9b6d319b0bff1bdf22aa389a25398d1952c7a0b5f3d93162dd8"}, "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"}, "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, - "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, - "mix_test_watch": {:hex, :mix_test_watch, "1.2.0", "1f9acd9e1104f62f280e30fc2243ae5e6d8ddc2f7f4dc9bceb454b9a41c82b42", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "278dc955c20b3fb9a3168b5c2493c2e5cffad133548d307e0a50c7f2cfbf34f6"}, + "mix_test_watch": {:hex, :mix_test_watch, "1.3.0", "2ffc9f72b0d1f4ecf0ce97b044e0e3c607c3b4dc21d6228365e8bc7c2856dc77", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "f9e5edca976857ffac78632e635750d158df14ee2d6185a15013844af7570ffe"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "oban": {:hex, :oban, "2.19.4", "045adb10db1161dceb75c254782f97cdc6596e7044af456a59decb6d06da73c1", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5fcc6219e6464525b808d97add17896e724131f498444a292071bf8991c99f97"}, "owl": {:hex, :owl, "0.12.2", "65906b525e5c3ef51bab6cba7687152be017aebe1da077bb719a5ee9f7e60762", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "6398efa9e1fea70a04d24231e10dcd66c1ac1aa2da418d20ef5357ec61de2880"}, - "phoenix": {:hex, :phoenix, "1.7.20", "6bababaf27d59f5628f9b608de902a021be2cecefb8231e1dbdc0a2e2e480e9b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "6be2ab98302e8784a31829e0d50d8bdfa81a23cd912c395bafd8b8bfb5a086c2"}, - "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.3", "f686701b0499a07f2e3b122d84d52ff8a31f5def386e03706c916f6feddf69ef", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "909502956916a657a197f94cc1206d9a65247538de8a5e186f7537c895d95764"}, + "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.5", "c4ef322acd15a574a8b1a08eff0ee0a85e73096b53ce1403b6563709f15e1cea", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "26ec3208eef407f31b748cadd044045c6fd485fbff168e35963d2f9dfff28d4b"}, "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"}, - "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.6", "7b1f0327f54c9eb69845fd09a77accf922f488c549a7e7b8618775eb603a62c7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "1681ab813ec26ca6915beb3414aa138f298e17721dc6a2bde9e6eb8a62360ff6"}, - "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.7", "491c5fcccb9cee4978a25f0ec4c4b01975cd5f8d6d2366ca1bd5bf6f7f81a862", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a1758c5816f65c83af38dfeef35a6d491363e32c707c2e3bb6b8f6339e8f2cbf"}, + "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.0", "2791fac0e2776b640192308cc90c0dbcf67843ad51387ed4ecae2038263d708d", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b3a1fa036d7eb2f956774eda7a7638cf5123f8f2175aca6d6420a7f95e598e1c"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.17", "beeb16d83a7d3760f7ad463df94e83b087577665d2acc0bf2987cd7d9778068f", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a4ca05c1eb6922c4d07a508a75bfa12c45e5f4d8f77ae83283465f02c53741e1"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, "picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"}, - "plug": {:hex, :plug, "1.17.0", "a0832e7af4ae0f4819e0c08dd2e7482364937aea6a8a997a679f2cbb7e026b2e", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6692046652a69a00a5a21d0b7e11fcf401064839d59d6b8787f23af55b1e6bc"}, - "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, + "plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, - "reactor": {:hex, :reactor, "0.15.0", "556937d9310e1a6dd06083592b9eb9e0d212540b6d82faecba70823ee7a0747d", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "f634383a7760ba3106d31a3185f2e2c39e1485d899d884d94c22c62c9b5e7a4a"}, - "req": {:hex, :req, "0.5.10", "a3a063eab8b7510785a467f03d30a8d95f66f5c3d9495be3474b61459c54376c", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "8a604815743f8a2d3b5de0659fa3137fa4b1cffd636ecb69b30b2b9b2c2559be"}, + "reactor": {:hex, :reactor, "0.15.6", "d717f9add549b25a089a94c90197718d2d838e35d81dd776b1d81587d4cf2aaa", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "74db98165e3644d86e0f723672d91ceca4339eaa935bcad7e78bf146a46d77b9"}, + "req": {:hex, :req, "0.5.14", "521b449fa0bf275e6d034c05f29bec21789a0d6cd6f7a1c326c7bee642bf6e07", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "b7b15692071d556c73432c7797aa7e96b51d1a2db76f746b976edef95c930021"}, "rewrite": {:hex, :rewrite, "1.1.2", "f5a5d10f5fed1491a6ff48e078d4585882695962ccc9e6c779bae025d1f92eda", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "7f8b94b1e3528d0a47b3e8b7bfeca559d2948a65fa7418a9ad7d7712703d39d4"}, "slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"}, - "sourceror": {:hex, :sourceror, "1.7.1", "599d78f4cc2be7d55c9c4fd0a8d772fd0478e3a50e726697c20d13d02aa056d4", [:mix], [], "hexpm", "cd6f268fe29fa00afbc535e215158680a0662b357dc784646d7dff28ac65a0fc"}, - "spark": {:hex, :spark, "2.2.48", "dd1005c26c7f98ea686a951f7ae58fffb54eff19c47830e6ff68b93f87433baa", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "379912647b9ddcc5265e91a82a235a264a727123d1f9e90052d91ad8cebbb2d0"}, - "spitfire": {:hex, :spitfire, "0.2.0", "0de1f519a23f65bde40d316adad53c07a9563f25cc68915d639d8a509a0aad8a", [:mix], [], "hexpm", "743daaee2d81a0d8095431729f478ce49b47ea8943c7d770de86704975cb7775"}, + "sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"}, + "spark": {:hex, :spark, "2.2.67", "67626cb9f59ea4b1c5aa85d4afdd025e0740cbd49ed82665d0a40ff007d7fd4b", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "c8575402e3afc66871362e821bece890536d16319cdb758c5fb2d1250182e46f"}, + "spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"}, "splode": {:hex, :splode, "0.2.9", "3a2776e187c82f42f5226b33b1220ccbff74f4bcc523dd4039c804caaa3ffdc7", [:mix], [], "hexpm", "8002b00c6e24f8bd1bcced3fbaa5c33346048047bb7e13d2f3ad428babbd95c3"}, - "stream_data": {:hex, :stream_data, "1.1.3", "15fdb14c64e84437901258bb56fc7d80aaf6ceaf85b9324f359e219241353bfb", [:mix], [], "hexpm", "859eb2be72d74be26c1c4f272905667672a52e44f743839c57c7ee73a1a66420"}, - "swoosh": {:hex, :swoosh, "1.18.3", "ca12197550bd7456654179055b1446168cc0f55067f784a3707e0e4462e269f5", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a533daccea84e887a061a919295212b37f4f2c7916436037eb8be7f1265bacba"}, + "stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"}, + "swoosh": {:hex, :swoosh, "1.19.3", "02ad4455939f502386e4e1443d4de94c514995fd0e51b3cafffd6bd270ffe81c", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "04a10f8496786b744b84130e3510eb53ca51e769c39511b65023bdf4136b732f"}, "tailwind": {:hex, :tailwind, "0.3.1", "a89d2835c580748c7a975ad7dd3f2ea5e63216dc16d44f9df492fbd12c094bed", [:mix], [], "hexpm", "98a45febdf4a87bc26682e1171acdedd6317d0919953c353fcd1b4f9f4b676a2"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, - "telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"}, + "telemetry_poller": {:hex, :telemetry_poller, "1.2.0", "ba82e333215aed9dd2096f93bd1d13ae89d249f82760fcada0850ba33bac154b", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7216e21a6c326eb9aa44328028c34e9fd348fb53667ca837be59d0aa2a0156e8"}, "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, - "thousand_island": {:hex, :thousand_island, "1.3.12", "590ff651a6d2a59ed7eabea398021749bdc664e2da33e0355e6c64e7e1a2ef93", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "55d0b1c868b513a7225892b8a8af0234d7c8981a51b0740369f3125f7c99a549"}, + "thousand_island": {:hex, :thousand_island, "1.3.14", "ad45ebed2577b5437582bcc79c5eccd1e2a8c326abf6a3464ab6c06e2055a34a", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d0d24a929d31cdd1d7903a4fe7f2409afeedff092d277be604966cd6aa4307ef"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, diff --git a/priv/repo/migrations/20250814182210_add_team_to_invitations.exs b/priv/repo/migrations/20250814182210_add_team_to_invitations.exs new file mode 100644 index 0000000..ca1b4c5 --- /dev/null +++ b/priv/repo/migrations/20250814182210_add_team_to_invitations.exs @@ -0,0 +1,17 @@ +defmodule Helpcenter.Repo.Migrations.AddTeamToInvitations do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + create unique_index(:teams, [:domain], name: "teams_unique_domain_index") + end + + def down do + drop_if_exists unique_index(:teams, [:domain], name: "teams_unique_domain_index") + end +end diff --git a/priv/repo/migrations/20250829202959_add_ash_parental.exs b/priv/repo/migrations/20250829202959_add_ash_parental.exs new file mode 100644 index 0000000..2ce5af5 --- /dev/null +++ b/priv/repo/migrations/20250829202959_add_ash_parental.exs @@ -0,0 +1,57 @@ +defmodule Helpcenter.Repo.Migrations.AddAshParental do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + create table(:invitations, primary_key: false) do + add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true + add :email, :text, null: false + add :status, :text, null: false, default: "pending" + add :token, :text, null: false + add :team, :text, null: false + add :expires_at, :utc_datetime, null: false + + add :inserted_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :updated_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :group_id, :uuid, null: false + + add :inviter_user_id, + references(:users, + column: :id, + name: "invitations_inviter_user_id_fkey", + type: :uuid, + prefix: "public" + ), + null: false + + add :invitee_user_id, + references(:users, + column: :id, + name: "invitations_invitee_user_id_fkey", + type: :uuid, + prefix: "public" + ) + end + end + + def down do + drop constraint(:invitations, "invitations_group_id_fkey") + + drop constraint(:invitations, "invitations_inviter_user_id_fkey") + + drop constraint(:invitations, "invitations_invitee_user_id_fkey") + + drop table(:invitations) + end +end diff --git a/priv/repo/tenant_migrations/20250814182208_add_team_to_invitations.exs b/priv/repo/tenant_migrations/20250814182208_add_team_to_invitations.exs new file mode 100644 index 0000000..57d2ab4 --- /dev/null +++ b/priv/repo/tenant_migrations/20250814182208_add_team_to_invitations.exs @@ -0,0 +1,67 @@ +defmodule Helpcenter.Repo.TenantMigrations.AddTeamToInvitations do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + + create table(:invitations, primary_key: false, prefix: prefix()) do + add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true + add :email, :text, null: false + add :status, :text, null: false, default: "pending" + add :token, :text, null: false + add :team, :text, null: false + add :expires_at, :utc_datetime, null: false + + add :inserted_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :updated_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :group_id, + references(:groups, + column: :id, + name: "invitations_group_id_fkey", + type: :uuid, + prefix: prefix() + ), + null: false + + add :inviter_user_id, + references(:users, + column: :id, + name: "invitations_inviter_user_id_fkey", + type: :uuid, + prefix: "public" + ), + null: false + + add :invitee_user_id, + references(:users, + column: :id, + name: "invitations_invitee_user_id_fkey", + type: :uuid, + prefix: "public" + ) + end + end + + def down do + drop constraint(:invitations, "invitations_group_id_fkey") + + drop constraint(:invitations, "invitations_inviter_user_id_fkey") + + drop constraint(:invitations, "invitations_invitee_user_id_fkey") + + drop table(:invitations, prefix: prefix()) + + drop_if_exists index(:user_notifications, [:recipient_user_id]) + end +end diff --git a/priv/repo/tenant_migrations/20250829202957_add_ash_parental.exs b/priv/repo/tenant_migrations/20250829202957_add_ash_parental.exs new file mode 100644 index 0000000..7ce6851 --- /dev/null +++ b/priv/repo/tenant_migrations/20250829202957_add_ash_parental.exs @@ -0,0 +1,30 @@ +defmodule Helpcenter.Repo.TenantMigrations.AddAshParental do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:comments, prefix: prefix()) do + add :parent_id, + references(:comments, + column: :id, + name: "comments_parent_id_fkey", + type: :uuid, + prefix: prefix() + ), + null: false + end + end + + def down do + drop constraint(:comments, "comments_parent_id_fkey") + + alter table(:comments, prefix: prefix()) do + remove :parent_id + end + end +end diff --git a/priv/repo/tenant_migrations/20250901141147_add_children_to_categories.exs b/priv/repo/tenant_migrations/20250901141147_add_children_to_categories.exs new file mode 100644 index 0000000..a82f8fb --- /dev/null +++ b/priv/repo/tenant_migrations/20250901141147_add_children_to_categories.exs @@ -0,0 +1,21 @@ +defmodule Helpcenter.Repo.TenantMigrations.AddChildrenToCategories do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:categories, prefix: prefix()) do + add :parent_id, :uuid + end + end + + def down do + alter table(:categories, prefix: prefix()) do + remove :parent_id + end + end +end diff --git a/priv/resource_snapshots/repo/invitations/20250829202959.json b/priv/resource_snapshots/repo/invitations/20250829202959.json new file mode 100644 index 0000000..cb42c19 --- /dev/null +++ b/priv/resource_snapshots/repo/invitations/20250829202959.json @@ -0,0 +1,208 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "email", + "type": "text" + }, + { + "allow_nil?": false, + "default": "\"pending\"", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "status", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "token", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "team", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "expires_at", + "type": "utc_datetime" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": false, + "strategy": "context" + }, + "name": "invitations_group_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "groups" + }, + "scale": null, + "size": null, + "source": "group_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "invitations_inviter_user_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "users" + }, + "scale": null, + "size": null, + "source": "inviter_user_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "invitations_invitee_user_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "users" + }, + "scale": null, + "size": null, + "source": "invitee_user_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "62A4CE516E55B26E46A1694CB732E668884347ECD19B5EF2777D47F62DF6BC71", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Helpcenter.Repo", + "schema": null, + "table": "invitations" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/teams/20250814182210.json b/priv/resource_snapshots/repo/teams/20250814182210.json new file mode 100644 index 0000000..3a97d4f --- /dev/null +++ b/priv/resource_snapshots/repo/teams/20250814182210.json @@ -0,0 +1,137 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"uuid_generate_v7()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "domain", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "description", + "type": "text" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "teams_owner_user_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "users" + }, + "scale": null, + "size": null, + "source": "owner_user_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "276437960B05C0DA3BCAFD936B7AA75EB9958B99F4353F40E141B81832E6B671", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "teams_unique_domain_index", + "keys": [ + { + "type": "atom", + "value": "domain" + } + ], + "name": "unique_domain", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Helpcenter.Repo", + "schema": null, + "table": "teams" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/tenants/categories/20250901141147.json b/priv/resource_snapshots/repo/tenants/categories/20250901141147.json new file mode 100644 index 0000000..76aa59c --- /dev/null +++ b/priv/resource_snapshots/repo/tenants/categories/20250901141147.json @@ -0,0 +1,103 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "slug", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "description", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "parent_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "637C2B6D0D87B21171304593DB5D971408BE94E3AAE2EA361A8E21E2BE35F355", + "identities": [], + "multitenancy": { + "attribute": null, + "global": false, + "strategy": "context" + }, + "repo": "Elixir.Helpcenter.Repo", + "schema": null, + "table": "categories" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/tenants/comments/20250829202957.json b/priv/resource_snapshots/repo/tenants/comments/20250829202957.json new file mode 100644 index 0000000..5643bc0 --- /dev/null +++ b/priv/resource_snapshots/repo/tenants/comments/20250829202957.json @@ -0,0 +1,129 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "content", + "type": "text" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": false, + "strategy": "context" + }, + "name": "comments_article_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "articles" + }, + "scale": null, + "size": null, + "source": "article_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": false, + "strategy": "context" + }, + "name": "comments_parent_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "comments" + }, + "scale": null, + "size": null, + "source": "parent_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "963C70D2E954AA318FC603E9284CD0B91FFA68AE2AA4277FEC18E623F9904FFB", + "identities": [], + "multitenancy": { + "attribute": null, + "global": false, + "strategy": "context" + }, + "repo": "Elixir.Helpcenter.Repo", + "schema": null, + "table": "comments" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/tenants/invitations/20250814182209.json b/priv/resource_snapshots/repo/tenants/invitations/20250814182209.json new file mode 100644 index 0000000..10e6cb2 --- /dev/null +++ b/priv/resource_snapshots/repo/tenants/invitations/20250814182209.json @@ -0,0 +1,208 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "email", + "type": "text" + }, + { + "allow_nil?": false, + "default": "\"pending\"", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "status", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "token", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "team", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "expires_at", + "type": "utc_datetime" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": false, + "strategy": "context" + }, + "name": "invitations_group_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "groups" + }, + "scale": null, + "size": null, + "source": "group_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "invitations_inviter_user_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "users" + }, + "scale": null, + "size": null, + "source": "inviter_user_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "invitations_invitee_user_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "users" + }, + "scale": null, + "size": null, + "source": "invitee_user_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "C943D079A2BEE09A33514945F44D9DF5DBA8DB94C5BC2C180E3A6DC9644C7ADE", + "identities": [], + "multitenancy": { + "attribute": null, + "global": false, + "strategy": "context" + }, + "repo": "Elixir.Helpcenter.Repo", + "schema": null, + "table": "invitations" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/tenants/user_notifications/20250814182209.json b/priv/resource_snapshots/repo/tenants/user_notifications/20250814182209.json new file mode 100644 index 0000000..98b3a8c --- /dev/null +++ b/priv/resource_snapshots/repo/tenants/user_notifications/20250814182209.json @@ -0,0 +1,158 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "sender_user_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "user_notifications_recipient_user_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "users" + }, + "scale": null, + "size": null, + "source": "recipient_user_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "subject", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "body", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "read_at", + "type": "utc_datetime" + }, + { + "allow_nil?": false, + "default": "\"unread\"", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "status", + "type": "text" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "processed", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "6723A2BAC63EA63C7BD5D5F03417BD29E5E305FBE7F8238456A3CDD66C0D64CB", + "identities": [], + "multitenancy": { + "attribute": null, + "global": false, + "strategy": "context" + }, + "repo": "Elixir.Helpcenter.Repo", + "schema": null, + "table": "user_notifications" +} \ No newline at end of file diff --git a/test/helpcenter/accounts/group_test.exs b/test/helpcenter/accounts/group_test.exs index 54d0689..47a73e2 100644 --- a/test/helpcenter/accounts/group_test.exs +++ b/test/helpcenter/accounts/group_test.exs @@ -18,7 +18,7 @@ defmodule Helpcenter.Accounts.GroupTest do Helpcenter.Accounts.Group, group_attrs, actor: user, - load: [:permissions, :users], + load: [:permissions], authorize?: false ) diff --git a/test/helpcenter/knowledge_base/category_test.exs b/test/helpcenter/knowledge_base/category_test.exs index 89f0587..669c226 100644 --- a/test/helpcenter/knowledge_base/category_test.exs +++ b/test/helpcenter/knowledge_base/category_test.exs @@ -1,5 +1,6 @@ defmodule Helpcenter.KnowledgeBase.CategoryTest do - use HelpcenterWeb.ConnCase, async: false + use HelpcenterWeb.ConnCase + import ArticleCase import CategoryCase require Ash.Query @@ -67,160 +68,160 @@ defmodule Helpcenter.KnowledgeBase.CategoryTest do |> Ash.exists?(actor: user) end - # test "Can destroy an existing Category" do - # user = create_user() - - # create_categories(user.current_team) - - # require Ash.Query - - # # First identify category to destroy - # category_to_delete = - # Helpcenter.KnowledgeBase.Category - # |> Ash.Query.filter(name == "Approvals and Workflows") - # |> Ash.read_first!(actor: user) - - # # Tell Ash to destroy it - # Ash.destroy(category_to_delete) - - # refute Helpcenter.KnowledgeBase.Category - # |> Ash.Query.filter(name == "Approvals and Workflows") - # |> Ash.exists?(actor: user) - # end - - # test "Category can be created with an article" do - # user = create_user() - - # # Define category and related article attributes - # attrs = %{ - # name: "Features", - # slug: "features", - # description: "Category for features", - # article_attrs: %{ - # title: "Compliance Features in Zippiker", - # slug: "compliance-features-zippiker", - # content: "Overview of compliance management features built into Zippiker." - # } - # } - - # # Create category and its article at the same time - # Helpcenter.KnowledgeBase.Category - # |> Ash.Changeset.for_create(:create_with_article, attrs, actor: user) - # |> Ash.create() - - # assert Helpcenter.KnowledgeBase.Category - # |> Ash.Query.filter(name == ^attrs.name) - # |> Ash.exists?(actor: user) - - # assert Helpcenter.KnowledgeBase.Article - # |> Ash.Query.filter(title == ^attrs.article_attrs.title) - # |> Ash.exists?(actor: user) - # end - - # test "An article can be added to an existing category" do - # # 1. Get category to create an article under - # user = create_user() - # category = get_category(user.current_team) - - # # 2. Prepare new article data - # attrs = %{ - # title: "Getting Started with Zippiker", - # slug: "getting-started-zippiker", - # content: "Learn how to set up your Zippiker account and configure basic settings.", - # views_count: 1452, - # published: true - # } - - # # 3 Create an article under this category - # {:ok, _category} = - # category - # |> Ash.Changeset.for_update(:add_article, %{article_attrs: attrs}, actor: user) - # |> Ash.update() - - # # Confirm that the article has been create - # assert Helpcenter.KnowledgeBase.Article - # |> Ash.Query.filter(title == ^attrs.title) - # |> Ash.Query.filter(content == ^attrs.content) - # |> Ash.Query.filter(category_id == ^category.id) - # |> Ash.read(actor: user) - # end - - # test "Category can be retrieved with related articles" do - # # First create articles for the category - # user = create_user() - - # category = get_category(user.current_team) - # articles = create_articles(category, user.current_team) - - # category_with_articles = - # Helpcenter.KnowledgeBase.Category - # |> Ash.Query.filter(id == ^category.id) - # # Tell Ash to load related articles - # |> Ash.Query.load(:articles) - # |> Ash.read_first!(actor: user) - - # # This category might have added article else where in concurrency writing. Thus, use <= - # assert Enum.count(category_with_articles.articles) <= Enum.count(articles) - # end - - # test "articles_count aggregate can be loaded on the category" do - # # Create categories and seed articles - # user = create_user() - - # category = get_category(user.current_team) - # create_articles(category, user.current_team) - - # loaded_category = - # Helpcenter.KnowledgeBase.Category - # |> Ash.Query.filter(id == ^category.id) - # |> Ash.Query.load([:article_count, :articles]) - # |> Ash.read_first!(actor: user) - - # assert loaded_category.article_count == Enum.count(loaded_category.articles) - # end - - # test "'categories' pubsub event is published on create" do - # # Subscribe to the event so we can test whether it is being fired - # user = create_user() - # HelpcenterWeb.Endpoint.subscribe("categories") - - # attributes = %{name: "Art 1", slug: "art-1", description: "descrpt-1"} - - # Helpcenter.KnowledgeBase.Category - # |> Ash.Changeset.for_create(:create, attributes, actor: user) - # |> Ash.create() - - # # Confirm that the event is being recieved and its data - # assert_receive %Phoenix.Socket.Broadcast{topic: "categories", payload: category} - # assert category.data.name == attributes.name - # assert category.data.slug == attributes.slug - # assert category.data.description == attributes.description - # end - - # test "Global preparations works as expected" do - # user = create_user() - - # create_categories(user.current_team) - - # assert Helpcenter.KnowledgeBase.Category - # |> Helpcenter.Preparations.LimitTo5.prepare([], []) - # |> Helpcenter.Preparations.MonthToDate.prepare([], []) - # |> Helpcenter.Preparations.OrderByMostRecent.prepare([], []) - # |> Ash.count!(actor: user) == 5 - # end - - # test "Slug change generates slug successfully" do - # user = create_user() - - # params = %{ - # name: "Home appliances you cannot find elsewhere", - # description: "Home appliances description" - # } - - # {:ok, category} = - # Ash.create(Helpcenter.KnowledgeBase.Category, params, tenant: user.current_team) - - # refute category.slug |> is_nil() - # end + test "Can destroy an existing Category" do + user = create_user() + + create_categories(user.current_team) + + require Ash.Query + + # First identify category to destroy + category_to_delete = + Helpcenter.KnowledgeBase.Category + |> Ash.Query.filter(name == "Approvals and Workflows") + |> Ash.read_first!(actor: user) + + # Tell Ash to destroy it + Ash.destroy(category_to_delete, actor: user) + + refute Helpcenter.KnowledgeBase.Category + |> Ash.Query.filter(name == "Approvals and Workflows") + |> Ash.exists?(actor: user) + end + + test "Category can be created with an article" do + user = create_user() + + # Define category and related article attributes + attrs = %{ + name: "Features", + slug: "features", + description: "Category for features", + article_attrs: %{ + title: "Compliance Features in Zippiker", + slug: "compliance-features-zippiker", + content: "Overview of compliance management features built into Zippiker." + } + } + + # Create category and its article at the same time + Helpcenter.KnowledgeBase.Category + |> Ash.Changeset.for_create(:create_with_article, attrs, actor: user) + |> Ash.create() + + assert Helpcenter.KnowledgeBase.Category + |> Ash.Query.filter(name == ^attrs.name) + |> Ash.exists?(actor: user) + + assert Helpcenter.KnowledgeBase.Article + |> Ash.Query.filter(title == ^attrs.article_attrs.title) + |> Ash.exists?(actor: user) + end + + test "An article can be added to an existing category" do + # 1. Get category to create an article under + user = create_user() + category = get_category(user.current_team) + + # 2. Prepare new article data + attrs = %{ + title: "Getting Started with Zippiker", + slug: "getting-started-zippiker", + content: "Learn how to set up your Zippiker account and configure basic settings.", + views_count: 1452, + published: true + } + + # 3 Create an article under this category + {:ok, _category} = + category + |> Ash.Changeset.for_update(:add_article, %{article_attrs: attrs}, actor: user) + |> Ash.update() + + # Confirm that the article has been create + assert Helpcenter.KnowledgeBase.Article + |> Ash.Query.filter(title == ^attrs.title) + |> Ash.Query.filter(content == ^attrs.content) + |> Ash.Query.filter(category_id == ^category.id) + |> Ash.read(actor: user) + end + + test "Category can be retrieved with related articles" do + # First create articles for the category + user = create_user() + + category = get_category(user.current_team) + articles = create_articles(category, user.current_team) + + category_with_articles = + Helpcenter.KnowledgeBase.Category + |> Ash.Query.filter(id == ^category.id) + # Tell Ash to load related articles + |> Ash.Query.load(:articles) + |> Ash.read_first!(actor: user) + + # This category might have added article else where in concurrency writing. Thus, use <= + assert Enum.count(category_with_articles.articles) <= Enum.count(articles) + end + + test "articles_count aggregate can be loaded on the category" do + # Create categories and seed articles + user = create_user() + + category = get_category(user.current_team) + create_articles(category, user.current_team) + + loaded_category = + Helpcenter.KnowledgeBase.Category + |> Ash.Query.filter(id == ^category.id) + |> Ash.Query.load([:article_count, :articles]) + |> Ash.read_first!(actor: user) + + assert loaded_category.article_count == Enum.count(loaded_category.articles) + end + + test "'categories' pubsub event is published on create" do + # Subscribe to the event so we can test whether it is being fired + user = create_user() + HelpcenterWeb.Endpoint.subscribe("categories") + + attributes = %{name: "Art 1", slug: "art-1", description: "descrpt-1"} + + Helpcenter.KnowledgeBase.Category + |> Ash.Changeset.for_create(:create, attributes, actor: user) + |> Ash.create() + + # Confirm that the event is being recieved and its data + assert_receive %Phoenix.Socket.Broadcast{topic: "categories", payload: category} + assert category.data.name == attributes.name + assert category.data.slug == attributes.slug + assert category.data.description == attributes.description + end + + test "Global preparations works as expected" do + user = create_user() + + create_categories(user.current_team) + + assert Helpcenter.KnowledgeBase.Category + |> Helpcenter.Preparations.LimitTo5.prepare([], []) + |> Helpcenter.Preparations.MonthToDate.prepare([], []) + |> Helpcenter.Preparations.OrderByMostRecent.prepare([], []) + |> Ash.count!(actor: user) == 5 + end + + test "Slug change generates slug successfully" do + user = create_user() + + params = %{ + name: "Home appliances you cannot find elsewhere", + description: "Home appliances description" + } + + {:ok, category} = + Ash.create(Helpcenter.KnowledgeBase.Category, params, actor: user) + + refute category.slug |> is_nil() + end end end