Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .formatter.exs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
[
import_deps: [
:cinder,
:ash_oban,
:oban,
:ash_authentication_phoenix,
Expand Down
1 change: 1 addition & 0 deletions assets/tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
# General application configuration
import Config

config :cinder, default_theme: "modern"
config :ash_oban, pro?: false

config :helpcenter, Oban,
Expand Down
13 changes: 7 additions & 6 deletions lib/helpcenter/accounts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion lib/helpcenter/accounts/group.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
150 changes: 150 additions & 0 deletions lib/helpcenter/accounts/invitation.ex
Original file line number Diff line number Diff line change
@@ -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
108 changes: 108 additions & 0 deletions lib/helpcenter/accounts/invitation/changes/add_user_to_team.ex
Original file line number Diff line number Diff line change
@@ -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
67 changes: 67 additions & 0 deletions lib/helpcenter/accounts/invitation/changes/send_declined_email.ex
Original file line number Diff line number Diff line change
@@ -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
"""
<p>Hello,</p>
<p>You have declined the invitation to join the <strong>#{team}</strong> team.</p>
<p>If this was a mistake, please contact the team administrator.</p>
"""
end

defp schedule_email(email_data) do
email_data
|> Helpcenter.Workers.EmailSender.new()
|> Oban.insert()
end
end
Loading
Loading