diff --git a/config/config.exs b/config/config.exs index 133016b665..f4cb498b21 100644 --- a/config/config.exs +++ b/config/config.exs @@ -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: [ diff --git a/lib/sanbase/billing/subscription/event_handling/billing_event_subscriber.ex b/lib/sanbase/billing/subscription/event_handling/billing_event_subscriber.ex index 8ba98d54d8..0f1cd575e6 100644 --- a/lib/sanbase/billing/subscription/event_handling/billing_event_subscriber.ex +++ b/lib/sanbase/billing/subscription/event_handling/billing_event_subscriber.ex @@ -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 @@ -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 @@ -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 diff --git a/lib/sanbase/discord/discord.ex b/lib/sanbase/discord/discord.ex new file mode 100644 index 0000000000..9ad6f2d663 --- /dev/null +++ b/lib/sanbase/discord/discord.ex @@ -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 diff --git a/lib/sanbase/discord/verification_code.ex b/lib/sanbase/discord/verification_code.ex new file mode 100644 index 0000000000..99c79139bc --- /dev/null +++ b/lib/sanbase/discord/verification_code.ex @@ -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) + + _ -> + 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 diff --git a/lib/sanbase/discord_bot/command_handler.ex b/lib/sanbase/discord_bot/command_handler.ex index 60d89addff..5952b7344b 100644 --- a/lib/sanbase/discord_bot/command_handler.ex +++ b/lib/sanbase/discord_bot/command_handler.ex @@ -17,6 +17,9 @@ defmodule Sanbase.DiscordBot.CommandHandler do @stage_bot_id 1_039_177_602_197_372_989 @prod_bot_id 1_039_814_526_708_764_742 + @local_pro_channel_id 852_836_291_116_138_507 + @prod_pro_channel_id 527_833_463_185_735_690 + @max_message_length 1950 @team_role_id 409_637_386_012_721_155 @@ -112,6 +115,69 @@ defmodule Sanbase.DiscordBot.CommandHandler do respond_to_component_interaction(interaction, context_id) end + def handle_interaction("verify", interaction, _metadata) do + Utils.interaction_ack_ephemeral(interaction) + + code = get_option_value(interaction.data.options, "code") + discord_user_id = to_string(interaction.user.id) + + case Sanbase.Discord.VerificationCode.verify_code(code, discord_user_id) do + {:ok, %{subscription_tier: _tier}} -> + # All paid tiers get PRO role + role_id = pro_role_id() + guild_id = santiment_guild_id() + + case Nostrum.Api.add_guild_member_role(guild_id, interaction.user.id, role_id) do + {:ok} -> + content = """ + ✅ Verification successful! You've been granted the PRO role. + """ + + Utils.edit_interaction_response(interaction, content, []) + send_welcome_message(interaction.user) + + {:error, error} -> + Logger.error( + "Failed to assign PRO role - Guild: #{guild_id}, User: #{interaction.user.id}, Role: #{role_id}, Error: #{inspect(error)}" + ) + + Utils.edit_interaction_response( + interaction, + "✅ Verification successful but failed to assign role. Please contact support.", + [] + ) + + unexpected -> + Logger.error( + "Unexpected return from add_guild_member_role: #{inspect(unexpected)} - Guild: #{guild_id}, User: #{interaction.user.id}, Role: #{role_id}" + ) + + Utils.edit_interaction_response( + interaction, + "✅ Verification successful but failed to assign role. Please contact support.", + [] + ) + end + + {:error, :invalid_code} -> + Utils.edit_interaction_response( + interaction, + "❌ Invalid verification code. Please check and try again.", + [] + ) + + {:error, :already_used} -> + Utils.edit_interaction_response(interaction, "❌ This code has already been used.", []) + + {:error, :expired} -> + Utils.edit_interaction_response( + interaction, + "❌ This code has expired. Please contact support.", + [] + ) + end + end + def access_denied(interaction) do Utils.interaction_ack_visible(interaction) @@ -637,4 +703,39 @@ defmodule Sanbase.DiscordBot.CommandHandler do Utils.handle_interaction_response(interaction, content, []) end + + defp pro_role_id() do + # Returns first PRO role ID based on environment + case Sanbase.Utils.Config.module_get(Sanbase, :deployment_env) do + "dev" -> @local_pro_roles |> List.first() + _ -> @prod_pro_roles |> List.first() + end + end + + defp send_welcome_message(discord_user) do + channel_id = pro_channel_id() + + if channel_id do + Nostrum.Api.create_message(channel_id, + content: "Welcome <@#{to_string(discord_user.id)}> to the exclusive PRO channel! 🎉" + ) + end + end + + defp pro_channel_id() do + env = Sanbase.Utils.Config.module_get(Sanbase, :deployment_env) + config_key = if env == "dev", do: @local_pro_channel_id, else: @prod_pro_channel_id + end + + defp parse_channel_id(nil), do: nil + defp parse_channel_id(id) when is_binary(id), do: String.to_integer(id) + + defp get_option_value(options, name) do + options + |> Enum.find(&(&1.name == name)) + |> case do + nil -> nil + option -> option.value + end + end end diff --git a/lib/sanbase/discord_bot/discord_consumer.ex b/lib/sanbase/discord_bot/discord_consumer.ex index 21c532da67..53756423bc 100644 --- a/lib/sanbase/discord_bot/discord_consumer.ex +++ b/lib/sanbase/discord_bot/discord_consumer.ex @@ -57,6 +57,19 @@ defmodule Sanbase.DiscordConsumer do autocomplete: true } ] + }, + %{ + name: "verify", + description: "Verify your Santiment subscription to get PRO role", + options: [ + %{ + # STRING type + type: 3, + name: "code", + description: "Your verification code (e.g., PRO-AB12CD)", + required: true + } + ] } ] @@ -85,6 +98,17 @@ defmodule Sanbase.DiscordConsumer do Nostrum.Api.bulk_overwrite_global_application_commands(@commands) end + def handle_event({ + :INTERACTION_CREATE, + %Interaction{data: %ApplicationCommandInteractionData{name: "verify"}} = interaction, + _ws_state + }) do + warm_up(interaction) + + CommandHandler.handle_interaction("verify", interaction, %{}) + |> handle_response("verify", interaction, %{}, retry: false) + end + def handle_event({ :INTERACTION_CREATE, %Interaction{data: %ApplicationCommandInteractionData{name: command}} = interaction, diff --git a/lib/sanbase/discord_bot/utils.ex b/lib/sanbase/discord_bot/utils.ex index 5238e9cea6..105862f2f2 100644 --- a/lib/sanbase/discord_bot/utils.ex +++ b/lib/sanbase/discord_bot/utils.ex @@ -51,6 +51,16 @@ defmodule Sanbase.DiscordBot.Utils do }) end + @spec interaction_ack_ephemeral(Nostrum.Struct.Interaction.t()) :: {:ok} | {:error, any()} + def interaction_ack_ephemeral(interaction) do + Nostrum.Api.create_interaction_response(interaction, %{ + # DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE + type: 5, + # EPHEMERAL flag + data: %{flags: @ephemeral_message_flags} + }) + end + @spec trim_message(String.t(), pos_integer()) :: String.t() def trim_message(message, max_length \\ 1950) do end_message = diff --git a/lib/sanbase_web/generic_admin/discord_verification_code.ex b/lib/sanbase_web/generic_admin/discord_verification_code.ex new file mode 100644 index 0000000000..2e72a49fe3 --- /dev/null +++ b/lib/sanbase_web/generic_admin/discord_verification_code.ex @@ -0,0 +1,17 @@ +defmodule SanbaseWeb.GenericAdmin.DiscordVerificationCode do + def schema_module, do: Sanbase.Discord.VerificationCode + + def resource() do + %{ + actions: [:show], + fields_override: %{ + discord_username: %{ + label: "Discord Username" + }, + discord_user_id: %{ + label: "Discord User ID" + } + } + } + end +end diff --git a/priv/repo/migrations/20251015073648_create_discord_verification_codes.exs b/priv/repo/migrations/20251015073648_create_discord_verification_codes.exs new file mode 100644 index 0000000000..3cf00f55a7 --- /dev/null +++ b/priv/repo/migrations/20251015073648_create_discord_verification_codes.exs @@ -0,0 +1,25 @@ +defmodule Sanbase.Repo.Migrations.CreateDiscordVerificationCodes do + use Ecto.Migration + + def change do + create table(:discord_verification_codes) do + add(:code, :string, null: false) + add(:user_id, references(:users, on_delete: :delete_all), null: false) + # Track tier for reference + add(:subscription_tier, :string, null: false) + # Set when verified + add(:discord_user_id, :string) + # Discord username (e.g., "user#1234") + add(:discord_username, :string) + add(:verified_at, :utc_datetime) + add(:expires_at, :utc_datetime, null: false) + add(:used, :boolean, default: false) + + timestamps() + end + + create(unique_index(:discord_verification_codes, [:code])) + create(index(:discord_verification_codes, [:user_id])) + create(index(:discord_verification_codes, [:discord_user_id])) + end +end diff --git a/priv/repo/structure.sql b/priv/repo/structure.sql index 0fad43f23b..050c22061a 100644 --- a/priv/repo/structure.sql +++ b/priv/repo/structure.sql @@ -2,8 +2,8 @@ -- PostgreSQL database dump -- --- Dumped from database version 15.10 (Homebrew) --- Dumped by pg_dump version 15.10 (Homebrew) +-- Dumped from database version 15.1 (Homebrew) +-- Dumped by pg_dump version 15.1 (Homebrew) SET statement_timeout = 0; SET lock_timeout = 0; @@ -899,7 +899,7 @@ CREATE TABLE public.chat_messages ( suggestions text[] DEFAULT ARRAY[]::text[], feedback_type character varying(255), CONSTRAINT valid_feedback_type CHECK ((((feedback_type)::text = ANY ((ARRAY['thumbs_up'::character varying, 'thumbs_down'::character varying])::text[])) OR (feedback_type IS NULL))), - CONSTRAINT valid_role CHECK (((role)::text = ANY (ARRAY[('user'::character varying)::text, ('assistant'::character varying)::text]))) + CONSTRAINT valid_role CHECK (((role)::text = ANY ((ARRAY['user'::character varying, 'assistant'::character varying])::text[]))) ); @@ -1379,6 +1379,44 @@ CREATE SEQUENCE public.discord_dashboards_id_seq ALTER SEQUENCE public.discord_dashboards_id_seq OWNED BY public.discord_dashboards.id; +-- +-- Name: discord_verification_codes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.discord_verification_codes ( + id bigint NOT NULL, + code character varying(255) NOT NULL, + user_id bigint NOT NULL, + subscription_tier character varying(255) NOT NULL, + discord_user_id character varying(255), + discord_username character varying(255), + verified_at timestamp(0) without time zone, + expires_at timestamp(0) without time zone NOT NULL, + used boolean DEFAULT false, + inserted_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + +-- +-- Name: discord_verification_codes_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.discord_verification_codes_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: discord_verification_codes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.discord_verification_codes_id_seq OWNED BY public.discord_verification_codes.id; + + -- -- Name: ecosystems; Type: TABLE; Schema: public; Owner: - -- @@ -5689,6 +5727,13 @@ ALTER TABLE ONLY public.dashboards_history ALTER COLUMN id SET DEFAULT nextval(' ALTER TABLE ONLY public.discord_dashboards ALTER COLUMN id SET DEFAULT nextval('public.discord_dashboards_id_seq'::regclass); +-- +-- Name: discord_verification_codes id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.discord_verification_codes ALTER COLUMN id SET DEFAULT nextval('public.discord_verification_codes_id_seq'::regclass); + + -- -- Name: ecosystems id; Type: DEFAULT; Schema: public; Owner: - -- @@ -6694,6 +6739,14 @@ ALTER TABLE ONLY public.discord_dashboards ADD CONSTRAINT discord_dashboards_pkey PRIMARY KEY (id); +-- +-- Name: discord_verification_codes discord_verification_codes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.discord_verification_codes + ADD CONSTRAINT discord_verification_codes_pkey PRIMARY KEY (id); + + -- -- Name: ecosystems ecosystems_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -8040,6 +8093,27 @@ CREATE INDEX discord_dashboards_dashboard_id_index ON public.discord_dashboards CREATE INDEX discord_dashboards_user_id_index ON public.discord_dashboards USING btree (user_id); +-- +-- Name: discord_verification_codes_code_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX discord_verification_codes_code_index ON public.discord_verification_codes USING btree (code); + + +-- +-- Name: discord_verification_codes_discord_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX discord_verification_codes_discord_user_id_index ON public.discord_verification_codes USING btree (discord_user_id); + + +-- +-- Name: discord_verification_codes_user_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX discord_verification_codes_user_id_index ON public.discord_verification_codes USING btree (user_id); + + -- -- Name: document_tokens_index; Type: INDEX; Schema: public; Owner: - -- @@ -9418,6 +9492,14 @@ ALTER TABLE ONLY public.discord_dashboards ADD CONSTRAINT discord_dashboards_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id); +-- +-- Name: discord_verification_codes discord_verification_codes_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.discord_verification_codes + ADD CONSTRAINT discord_verification_codes_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + -- -- Name: eth_accounts eth_accounts_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -11050,7 +11132,6 @@ INSERT INTO public."schema_migrations" (version) VALUES (20250806103908); INSERT INTO public."schema_migrations" (version) VALUES (20250821111317); INSERT INTO public."schema_migrations" (version) VALUES (20250825074648); INSERT INTO public."schema_migrations" (version) VALUES (20250904142224); -INSERT INTO public."schema_migrations" (version) VALUES (20250911114116); INSERT INTO public."schema_migrations" (version) VALUES (20250918083815); INSERT INTO public."schema_migrations" (version) VALUES (20250918093232); INSERT INTO public."schema_migrations" (version) VALUES (20250918110902); @@ -11062,4 +11143,5 @@ INSERT INTO public."schema_migrations" (version) VALUES (20250926101337); INSERT INTO public."schema_migrations" (version) VALUES (20250926101756); INSERT INTO public."schema_migrations" (version) VALUES (20250926115345); INSERT INTO public."schema_migrations" (version) VALUES (20251013121803); +INSERT INTO public."schema_migrations" (version) VALUES (20251015073648); INSERT INTO public."schema_migrations" (version) VALUES (20251014144144); diff --git a/test/sanbase/discord/verification_code_test.exs b/test/sanbase/discord/verification_code_test.exs new file mode 100644 index 0000000000..42f63db1b1 --- /dev/null +++ b/test/sanbase/discord/verification_code_test.exs @@ -0,0 +1,196 @@ +defmodule Sanbase.Discord.VerificationCodeTest do + use Sanbase.DataCase, async: true + + import Sanbase.Factory + + alias Sanbase.Discord.VerificationCode + + describe "generate_code/2" do + test "generates a unique code for a user and tier" do + user = insert(:user) + tier = "PRO" + + {:ok, verification_code} = VerificationCode.generate_code(user.id, tier) + + assert verification_code.user_id == user.id + assert verification_code.subscription_tier == tier + assert verification_code.code =~ ~r/^PRO-/ + # PRO- + 6 chars + assert String.length(verification_code.code) == 10 + assert verification_code.used == false + assert verification_code.verified_at == nil + assert verification_code.discord_user_id == nil + assert verification_code.discord_username == nil + end + + test "generates different codes for different users" do + user1 = insert(:user) + user2 = insert(:user) + tier = "PRO" + + {:ok, code1} = VerificationCode.generate_code(user1.id, tier) + {:ok, code2} = VerificationCode.generate_code(user2.id, tier) + + refute code1.code == code2.code + end + + test "generates codes with PRO prefix regardless of tier" do + user = insert(:user) + + # Test all tiers generate PRO- prefix + for tier <- ["PRO", "MAX", "BUSINESS_PRO", "BUSINESS_MAX"] do + {:ok, verification_code} = VerificationCode.generate_code(user.id, tier) + assert String.starts_with?(verification_code.code, "PRO-") + # PRO-XXXXXX + assert String.length(verification_code.code) == 10 + end + end + + test "cleans up existing codes for user before generating new one" do + user = insert(:user) + tier = "PRO" + + # Generate first code + {:ok, _code1} = VerificationCode.generate_code(user.id, tier) + + # Generate second code - should clean up first + {:ok, code2} = VerificationCode.generate_code(user.id, tier) + + # Should only have one active code + active_codes = + from(vc in VerificationCode, where: vc.user_id == ^user.id) + |> Sanbase.Repo.all() + + assert length(active_codes) == 1 + assert hd(active_codes).id == code2.id + end + end + + describe "verify_code/2" do + test "successfully verifies a valid code" do + user = insert(:user) + tier = "PRO" + discord_user_id = "123456789" + + {:ok, verification_code} = VerificationCode.generate_code(user.id, tier) + + {:ok, updated_code} = VerificationCode.verify_code(verification_code.code, discord_user_id) + + assert updated_code.used == true + assert updated_code.verified_at != nil + assert updated_code.discord_user_id == discord_user_id + assert updated_code.discord_username != nil + end + + test "returns error for invalid code" do + discord_user_id = "123456789" + + {:error, :invalid_code} = VerificationCode.verify_code("INVALID-CODE", discord_user_id) + end + + test "returns error for already used code" do + user = insert(:user) + tier = "PRO" + discord_user_id = "123456789" + + {:ok, verification_code} = VerificationCode.generate_code(user.id, tier) + + # Verify once + {:ok, _} = VerificationCode.verify_code(verification_code.code, discord_user_id) + + # Try to verify again + {:error, :already_used} = VerificationCode.verify_code(verification_code.code, "987654321") + end + + test "returns error for expired code" do + user = insert(:user) + tier = "PRO" + discord_user_id = "123456789" + + # Create an expired code + expired_code = + %VerificationCode{ + code: "PRO-EXPIRED-#{user.id}", + user_id: user.id, + subscription_tier: tier, + # 1 second ago + expires_at: DateTime.add(DateTime.utc_now(), -1) |> DateTime.truncate(:second), + used: false + } + |> Sanbase.Repo.insert!() + + {:error, :expired} = VerificationCode.verify_code(expired_code.code, discord_user_id) + end + end + + describe "get_active_code_for_user/1" do + test "returns active code for user" do + user = insert(:user) + tier = "PRO" + + {:ok, verification_code} = VerificationCode.generate_code(user.id, tier) + + active_code = VerificationCode.get_active_code_for_user(user.id) + + assert active_code.id == verification_code.id + end + + test "returns nil when no active code exists" do + user = insert(:user) + + assert VerificationCode.get_active_code_for_user(user.id) == nil + end + + test "returns nil for expired code" do + user = insert(:user) + tier = "PRO" + + # Create an expired code + %VerificationCode{ + code: "PRO-EXPIRED-#{user.id}", + user_id: user.id, + subscription_tier: tier, + # 1 second ago + expires_at: DateTime.add(DateTime.utc_now(), -1) |> DateTime.truncate(:second), + used: false + } + |> Sanbase.Repo.insert!() + + assert VerificationCode.get_active_code_for_user(user.id) == nil + end + end + + describe "cleanup_expired/0" do + test "removes expired codes" do + user1 = insert(:user) + user2 = insert(:user) + tier = "PRO" + + # Create expired code for user1 + %VerificationCode{ + code: "PRO-EXPIRED-#{user1.id}", + user_id: user1.id, + subscription_tier: tier, + # 1 second ago + expires_at: DateTime.add(DateTime.utc_now(), -1) |> DateTime.truncate(:second), + used: false + } + |> Sanbase.Repo.insert!() + + # Create valid code for user2 + {:ok, _valid_code} = VerificationCode.generate_code(user2.id, tier) + + # Cleanup expired codes + {count, _} = VerificationCode.cleanup_expired() + + assert count >= 1 + + # Should only have the valid code left + active_codes = + from(vc in VerificationCode, where: vc.user_id == ^user2.id) + |> Sanbase.Repo.all() + + assert length(active_codes) == 1 + end + end +end diff --git a/test/support/billing/test_seed.ex b/test/support/billing/test_seed.ex index 27993a1876..7074ff0ecc 100644 --- a/test/support/billing/test_seed.ex +++ b/test/support/billing/test_seed.ex @@ -44,8 +44,20 @@ defmodule Sanbase.Billing.TestSeed do data _ -> - [{@key, data}] = :ets.lookup(ets_table, @key) - data + case :ets.lookup(ets_table, @key) do + [{@key, data}] -> data + [] -> seed_products_and_plans_from_db() + end end end + + defp seed_products_and_plans_from_db() do + product_api = Sanbase.Repo.get_by(Sanbase.Billing.Product, name: "API") + product_sanbase = Sanbase.Repo.get_by(Sanbase.Billing.Product, name: "Sanbase") + + %{ + product_api: product_api, + product_sanbase: product_sanbase + } + end end