Skip to content
Open
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
4 changes: 4 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,10 @@ config :sanbase, Sanbase.Affiliate.FirstPromoterApi,
api_id: {:system, "FIRST_PROMOTER_API_ID"},
api_key: {:system, "FIRST_PROMOTER_API_KEY"}

config :sanbase, Sanbase.Discord,
invite_url: {:system, "DISCORD_INVITE_URL", "https://discord.gg/EJrZR8GHZU"},
verification_code_expiry_days: {:system, "DISCORD_VERIFICATION_EXPIRY_DAYS", "7"}

config :sanbase, Oban.Web,
repo: Sanbase.Repo,
queues: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ defmodule Sanbase.EventBus.BillingEventSubscriber do
:update_api_call_limit_table,
:send_discord_notification,
:unfreeze_user_frozen_alerts,
:sanbase_pro_started
:sanbase_pro_started,
:generate_discord_verification_code
]

@doc false
Expand Down Expand Up @@ -123,6 +124,20 @@ defmodule Sanbase.EventBus.BillingEventSubscriber do
end
end

defp do_handle(:generate_discord_verification_code, event_type, event)
when event_type == :create_subscription do
subscription = Subscription.by_id(event.data.subscription_id)

# Generate code for any active paid subscription
if active_paid_subscription?(subscription) do
tier = subscription_tier_name(subscription)

if tier do
Sanbase.Discord.VerificationCode.generate_code(subscription.user_id, tier)
end
end
end

defp do_handle(_type, _event_type, _event) do
:ok
end
Expand All @@ -137,4 +152,19 @@ defmodule Sanbase.EventBus.BillingEventSubscriber do
:ok
end
end

defp active_paid_subscription?(subscription) do
subscription && subscription.status == :active
end

defp subscription_tier_name(%Subscription{plan: plan}) do
# Map plan to tier name for tracking purposes
cond do
plan.name =~ ~r/BUSINESS.*PRO/i -> "BUSINESS_PRO"
plan.name =~ ~r/BUSINESS.*MAX/i -> "BUSINESS_MAX"
plan.name =~ ~r/PRO/i -> "PRO"
plan.name =~ ~r/MAX/i -> "MAX"
true -> nil
end
end
end
42 changes: 42 additions & 0 deletions lib/sanbase/discord/discord.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
defmodule Sanbase.Discord do
@moduledoc """
Context module for Discord-related functionality.
"""

alias Sanbase.Discord.VerificationCode

@doc """
Get verification information for a user.
Returns nil if no active verification code exists.
"""
@spec get_verification_info(integer()) :: map() | nil
def get_verification_info(user_id) do
case VerificationCode.get_active_code_for_user(user_id) do
nil ->
nil

verification_code ->
%{
code: verification_code.code,
invite_url: discord_invite_url(),
verified: verification_code.verified_at != nil,
tier: verification_code.subscription_tier,
discord_username: verification_code.discord_username
}
end
end

@doc """
Get the configured Discord invite URL.
"""
@spec discord_invite_url() :: String.t()
def discord_invite_url do
Application.get_env(:sanbase, Sanbase.Discord, [])
|> Keyword.get(:invite_url, "https://discord.gg/EJrZR8GHZU")
|> case do
{:system, env_var, default} -> System.get_env(env_var, default)
url when is_binary(url) -> url
_ -> "https://discord.gg/EJrZR8GHZU"
end
end
end
226 changes: 226 additions & 0 deletions lib/sanbase/discord/verification_code.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
defmodule Sanbase.Discord.VerificationCode do
@moduledoc """
Schema and functions for managing Discord verification codes.

When a user subscribes to a paid tier, a unique verification code is generated.
Users can use this code in Discord with the /verify command to get the PRO role.
"""

use Ecto.Schema
import Ecto.Query
import Ecto.Changeset

alias Sanbase.Repo

@type t :: %__MODULE__{}

schema "discord_verification_codes" do
field(:code, :string)
field(:subscription_tier, :string)
field(:discord_user_id, :string)
field(:discord_username, :string)
field(:verified_at, :utc_datetime)
field(:expires_at, :utc_datetime)
field(:used, :boolean, default: false)

belongs_to(:user, Sanbase.Accounts.User)

timestamps()
end

@doc """
Generate a new verification code for a user and subscription tier.
"""
@spec generate_code(integer(), String.t()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
def generate_code(user_id, tier) do
# Clean up any existing codes for this user
cleanup_user_codes(user_id)

code = generate_unique_code(tier)
expires_at = DateTime.add(DateTime.utc_now(), expiry_days() * 24 * 60 * 60)

%__MODULE__{}
|> changeset(%{
code: code,
user_id: user_id,
subscription_tier: tier,
expires_at: expires_at
})
|> Repo.insert()
end

@doc """
Verify a code and link it to a Discord user.
"""
@spec verify_code(String.t(), String.t()) :: {:ok, t()} | {:error, atom()}
def verify_code(code, discord_user_id) do
query =
from(vc in __MODULE__,
where: vc.code == ^code
)

with {:ok, verification_code} <- find_code(query),
:ok <- check_not_used(verification_code),
:ok <- check_not_expired(verification_code) do
update_code_with_discord_user(verification_code, discord_user_id)
end
end

defp find_code(query) do
case Repo.one(query) do
nil -> {:error, :invalid_code}
code -> {:ok, code}
end
end

defp check_not_used(verification_code) do
if verification_code.used do
{:error, :already_used}
else
:ok
end
end

defp check_not_expired(verification_code) do
if DateTime.compare(verification_code.expires_at, DateTime.utc_now()) == :gt do
:ok
else
{:error, :expired}
end
end

defp update_code_with_discord_user(verification_code, discord_user_id) do
discord_username = get_discord_username(discord_user_id)

verification_code
|> changeset(%{
discord_user_id: discord_user_id,
discord_username: discord_username,
verified_at: DateTime.utc_now(),
used: true
})
|> Repo.update()
|> case do
{:ok, updated_code} -> {:ok, updated_code}
{:error, _changeset} -> {:error, :verification_failed}
end
end

@doc """
Get an active verification code for a specific user and tier.
"""
@spec get_active_code(integer(), String.t()) :: t() | nil
def get_active_code(user_id, tier) do
query =
from(vc in __MODULE__,
where: vc.user_id == ^user_id,
where: vc.subscription_tier == ^tier,
where: vc.used == false,
where: vc.expires_at > ^DateTime.utc_now(),
order_by: [desc: vc.inserted_at],
limit: 1
)

Repo.one(query)
end

@doc """
Get any active verification code for a user (any tier).
"""
@spec get_active_code_for_user(integer()) :: t() | nil
def get_active_code_for_user(user_id) do
query =
from(vc in __MODULE__,
where: vc.user_id == ^user_id,
where: vc.used == false,
where: vc.expires_at > ^DateTime.utc_now(),
order_by: [desc: vc.inserted_at],
limit: 1
)

Repo.one(query)
end

@doc """
Clean up expired verification codes.
"""
@spec cleanup_expired() :: {integer(), nil | [term()]}
def cleanup_expired do
query =
from(vc in __MODULE__,
where: vc.expires_at < ^DateTime.utc_now()
)

Repo.delete_all(query)
end

@doc """
Delete all verification codes for a user (for testing).
"""
@spec cleanup_user_codes(integer()) :: {integer(), nil | [term()]}
def cleanup_user_codes(user_id) do
query =
from(vc in __MODULE__,
where: vc.user_id == ^user_id
)

Repo.delete_all(query)
end

def changeset(verification_code, attrs) do
verification_code
|> cast(attrs, [
:code,
:user_id,
:subscription_tier,
:discord_user_id,
:discord_username,
:verified_at,
:expires_at,
:used
])
|> validate_required([:code, :user_id, :subscription_tier, :expires_at])
|> validate_length(:code, min: 1, max: 20)
|> validate_inclusion(:subscription_tier, ["PRO", "MAX", "BUSINESS_PRO", "BUSINESS_MAX"])
|> unique_constraint(:code)
end

# Private functions

defp generate_unique_code(_tier) do
random_part = generate_random_string(6)
"PRO-#{random_part}"
end

defp generate_random_string(length) do
chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

for _ <- 1..length, into: "" do
String.at(chars, :rand.uniform(String.length(chars)) - 1)
end
end

defp expiry_days do
Application.get_env(:sanbase, Sanbase.Discord, [])
|> Keyword.get(:verification_code_expiry_days, 30)
|> case do
{:system, env_var, default} ->
System.get_env(env_var, default) |> String.to_integer()

days when is_integer(days) ->
days

days when is_binary(days) ->
String.to_integer(days)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Config Parsing Error Causes Application Crash

The expiry_days/0 function uses String.to_integer/1 without error handling. If the verification_code_expiry_days configuration value is not a valid integer string, it raises an ArgumentError, which can crash the application.

Fix in Cursor Fix in Web


_ ->
30
end
end

defp get_discord_username(discord_user_id) do
# This would ideally fetch from Discord API, but for now we'll use a placeholder
# In a real implementation, you'd call Discord API to get the username
"user_#{String.slice(discord_user_id, -4, 4)}"
end
end
Loading