neiam
/feedpug
feedpub
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Add invite-only registration + shared Schema module
df5dc2a · gmorell · 2026-06-23 20:20
Message
{commit_body(@commit)}
Files changed
modified
config/config.exs
+14
−2
| 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 | 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 | 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 | 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 | 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 | 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 | 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