Add invite-only registration + shared Schema module

df5dc2a · gmorell · 2026-06-23 20:20

61 files +593 -54
Message
{commit_body(@commit)}

Files changed

modified config/config.exs
+14 −2
@@ -14,7 +14,7 @@ config :feed_pug, :scopes,
14 14 assign_key: :current_scope,
15 15 access_path: [:user, :id],
16 16 schema_key: :user_id,
17 − schema_type: :id,
17 + schema_type: :binary_id,
18 18 schema_table: :users,
19 19 test_data_fixture: FeedPug.AccountsFixtures,
20 20 test_setup_helper: :register_and_log_in_user
@@ -22,7 +22,19 @@ config :feed_pug, :scopes,
22 22
23 23 config :feed_pug,
24 24 ecto_repos: [FeedPug.Repo],
25 − generators: [timestamp_type: :utc_datetime]
25 + generators: [timestamp_type: :utc_datetime, binary_id: true]
26 +
27 +# When false, /users/register only accepts visitors who arrived via a valid
28 +# invite token (/users/register?invite=...). Existing users mint invites at
29 +# /users/invites. Default closed; dev/test open it, prod reads REGISTRATION_OPEN.
30 +config :feed_pug, :registration_open, false
31 +
32 +# Use UUIDv7 (time-ordered) binary primary/foreign keys everywhere. Schemas use
33 +# `FeedPug.Schema` (UUIDv7 type, autogenerated app-side); migrations default to
34 +# binary_id columns. Oban manages its own integer ids separately.
35 +config :feed_pug, FeedPug.Repo,
36 + migration_primary_key: [name: :id, type: :binary_id],
37 + migration_foreign_key: [type: :binary_id]
26 38
27 39 # Oban: background feed polling
28 40 config :feed_pug, Oban,
modified config/dev.exs
+3 −0
@@ -91,3 +91,6 @@ config :phoenix_live_view,
91 91
92 92 # Disable swoosh api client as it is only required for production adapters.
93 93 config :swoosh, :api_client, false
94 +
95 +# Open registration locally (prod is gated — see config.exs / runtime.exs).
96 +config :feed_pug, :registration_open, true
modified config/runtime.exs
+4 −0
@@ -73,6 +73,10 @@ if config_env() == :prod do
73 73
74 74 config :feed_pug, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
75 75
76 + # Public self-registration is closed by default; set REGISTRATION_OPEN=true to
77 + # let anyone register without an invite. (Invites always work regardless.)
78 + config :feed_pug, :registration_open, System.get_env("REGISTRATION_OPEN") == "true"
79 +
76 80 # libcluster — Kubernetes lookup of peer pods so Phoenix.PubSub fans the live
77 81 # newsfeed across replicas. Only enabled when LIBCLUSTER_KUBERNETES=true (set
78 82 # in the multi-replica deployment); otherwise the topology list stays empty
modified config/test.exs
+3 −0
@@ -46,3 +46,6 @@ config :phoenix_live_view,
46 46 # Sort query params output of verified routes for robust url comparisons
47 47 config :phoenix,
48 48 sort_verified_routes_query_params: true
49 +
50 +# The generated registration tests self-register without an invite.
51 +config :feed_pug, :registration_open, true
modified lib/feed_pug/accounts.ex
+79 −1
@@ -6,7 +6,7 @@ defmodule FeedPug.Accounts do
6 6 import Ecto.Query, warn: false
7 7 alias FeedPug.Repo
8 8
9 − alias FeedPug.Accounts.{User, UserToken, UserNotifier, ApiToken, Scope}
9 + alias FeedPug.Accounts.{User, UserToken, UserNotifier, ApiToken, Scope, Invite}
10 10
11 11 ## Database getters
12 12
@@ -169,6 +169,84 @@ defmodule FeedPug.Accounts do
169 169 end
170 170 end
171 171
172 + ## Invites
173 +
174 + @doc """
175 + Returns true when public registration is open. When false, registration
176 + requires a valid invite token.
177 + """
178 + @spec registration_open?() :: boolean()
179 + def registration_open? do
180 + Application.get_env(:feed_pug, :registration_open, false) == true
181 + end
182 +
183 + @doc "Creates an invite token, owned by the given user."
184 + def create_invite(%User{id: id}, attrs \\ %{}) do
185 + attrs =
186 + attrs
187 + |> Map.new(fn {k, v} -> {to_string(k), v} end)
188 + |> Map.put("created_by_id", id)
189 +
190 + %Invite{}
191 + |> Invite.create_changeset(attrs)
192 + |> Repo.insert()
193 + end
194 +
195 + @doc """
196 + Creates an unowned (system) invite — used to bootstrap the first account when
197 + registration is gated and there is no logged-in user yet.
198 + """
199 + def create_system_invite(attrs \\ %{}) do
200 + attrs =
201 + attrs
202 + |> Map.new(fn {k, v} -> {to_string(k), v} end)
203 + |> Map.delete("created_by_id")
204 +
205 + %Invite{}
206 + |> Invite.create_changeset(attrs)
207 + |> Repo.insert()
208 + end
209 +
210 + @doc "Lists invites created by a user, newest first, with the redeemer preloaded."
211 + def list_invites(%User{id: id}) do
212 + from(i in Invite,
213 + where: i.created_by_id == ^id,
214 + order_by: [desc: i.inserted_at],
215 + preload: [:consumed_by]
216 + )
217 + |> Repo.all()
218 + end
219 +
220 + @doc "Looks up an invite by token and returns it if active."
221 + @spec get_active_invite(binary()) :: Invite.t() | nil
222 + def get_active_invite(token) when is_binary(token) do
223 + case Repo.get_by(Invite, token: token) do
224 + nil -> nil
225 + invite -> if Invite.active?(invite), do: invite, else: nil
226 + end
227 + end
228 +
229 + def get_active_invite(_), do: nil
230 +
231 + @doc "Marks an invite as consumed by the given user."
232 + def consume_invite(%Invite{} = invite, %User{} = user) do
233 + invite
234 + |> Invite.consume_changeset(user)
235 + |> Repo.update()
236 + end
237 +
238 + @doc "Revokes an unused invite the user owns (stamps consumed_at, no consumer)."
239 + def revoke_invite(%User{id: owner_id}, %Invite{created_by_id: owner_id} = invite) do
240 + invite
241 + |> Ecto.Changeset.change(
242 + consumed_at: DateTime.utc_now() |> DateTime.truncate(:second),
243 + consumed_by_id: nil
244 + )
245 + |> Repo.update()
246 + end
247 +
248 + def revoke_invite(_, _), do: {:error, :forbidden}
249 +
172 250 ## Settings
173 251
174 252 @doc """
modified lib/feed_pug/accounts/api_token.ex
+1 −1

Click to load diff…

added lib/feed_pug/accounts/invite.ex
+68 −0

Click to load diff…

modified lib/feed_pug/accounts/user.ex
+1 −1

Click to load diff…

modified lib/feed_pug/accounts/user_token.ex
+1 −1

Click to load diff…

modified lib/feed_pug/feeds.ex
+13 −12

Click to load diff…

modified lib/feed_pug/feeds/feed.ex
+1 −1

Click to load diff…

modified lib/feed_pug/feeds/item.ex
+1 −1

Click to load diff…

modified lib/feed_pug/feeds/item_read.ex
+1 −1

Click to load diff…

modified lib/feed_pug/groups/follow_exclusion.ex
+1 −1

Click to load diff…

modified lib/feed_pug/groups/group.ex
+1 −1

Click to load diff…

modified lib/feed_pug/groups/group_feed.ex
+1 −1

Click to load diff…

modified lib/feed_pug/groups/group_follow.ex
+1 −1

Click to load diff…

modified lib/feed_pug/reactions.ex
+1 −0

Click to load diff…

modified lib/feed_pug/reactions/item_reaction.ex
+1 −1

Click to load diff…

modified lib/feed_pug/reactions/reaction.ex
+1 −1

Click to load diff…

modified lib/feed_pug/release.ex
+28 −0

Click to load diff…

added lib/feed_pug/schema.ex
+17 −0

Click to load diff…

modified lib/feed_pug/timelines/slice.ex
+1 −1

Click to load diff…

modified lib/feed_pug_web/components/layouts.ex
+5 −0

Click to load diff…

modified lib/feed_pug_web/controllers/api/item_controller.ex
+4 −5

Click to load diff…

modified lib/feed_pug_web/live/discover_live.ex
+1 −2

Click to load diff…

modified lib/feed_pug_web/live/newsfeed_live.ex
+0 −1

Click to load diff…

added lib/feed_pug_web/live/user_live/invites.ex
+122 −0

Click to load diff…

modified lib/feed_pug_web/live/user_live/registration.ex
+52 −17

Click to load diff…

modified lib/feed_pug_web/router.ex
+1 −0

Click to load diff…

modified mix.exs
+1 −0

Click to load diff…

modified mix.lock
+1 −0

Click to load diff…

added priv/repo/migrations/20260623173719_create_invites.exs
+19 −0

Click to load diff…

added rel/overlays/bin/invite
+5 −0

Click to load diff…

modified test/feed_pug/accounts_test.exs
+1 −1

Click to load diff…

added test/feed_pug/invites_test.exs
+78 −0

Click to load diff…

added test/feed_pug_web/live/invites_live_test.exs
+60 −0

Click to load diff…

Parents: 608eff1