This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

try an approach of doing it all in one big swoop. middling success!

+678 -1
+2
lib/bookmarker/application.ex
··· 16 16 {Finch, name: Bookmarker.Finch}, 17 17 # Start the ETS cache for bookmarks 18 18 {Bookmarker.Cache, []}, 19 + # Start the Notifications cache 20 + {Bookmarker.NotificationsCache, []}, 19 21 # Start the Bluesky connection for simulating bookmark traffic 20 22 {BlueskyHose, []}, 21 23 # Start to serve requests, typically the last entry
+4
lib/bookmarker/bookmarks.ex
··· 7 7 alias Bookmarker.Repo 8 8 alias Bookmarker.Bookmarks.Bookmark 9 9 alias Bookmarker.Tags 10 + alias Bookmarker.Notifications 10 11 alias Phoenix.PubSub 11 12 12 13 @topic "bookmarks" ··· 264 265 # Broadcast to user's tag topics 265 266 Enum.each(bookmark.tags, fn tag -> 266 267 broadcast_to_user_tag(bookmark.user.username, tag.name, {:bookmark_created, bookmark}) 268 + 269 + # Create notifications for users following this tag 270 + Notifications.create_tag_bookmark_notifications(bookmark, tag) 267 271 end) 268 272 269 273 {:ok, bookmark}
+232
lib/bookmarker/notifications.ex
··· 1 + defmodule Bookmarker.Notifications do 2 + @moduledoc """ 3 + The Notifications context handles all tag follows and user notifications. 4 + """ 5 + 6 + import Ecto.Query, warn: false 7 + alias Bookmarker.Repo 8 + alias Phoenix.PubSub 9 + alias Bookmarker.Notifications.Notification 10 + alias Bookmarker.Tags.{Tag, TagFollow} 11 + alias Bookmarker.Accounts.User 12 + alias Bookmarker.Bookmarks.Bookmark 13 + 14 + # ETS table name for unread notifications 15 + @notifications_table :unread_notifications_cache 16 + # Maximum number of days to keep notifications in the database 17 + @max_notification_days 30 18 + 19 + # TagFollow functions 20 + 21 + @doc """ 22 + Returns all tags followed by a user. 23 + """ 24 + def list_followed_tags(%User{} = user) do 25 + TagFollow 26 + |> where([tf], tf.user_id == ^user.id) 27 + |> join(:inner, [tf], t in Tag, on: tf.tag_id == t.id) 28 + |> select([_tf, t], t) 29 + |> Repo.all() 30 + end 31 + 32 + @doc """ 33 + Returns all users following a tag. 34 + """ 35 + def list_tag_followers(%Tag{} = tag) do 36 + TagFollow 37 + |> where([tf], tf.tag_id == ^tag.id) 38 + |> join(:inner, [tf], u in User, on: tf.user_id == u.id) 39 + |> select([_tf, u], u) 40 + |> Repo.all() 41 + end 42 + 43 + @doc """ 44 + Follows a tag for a user. 45 + """ 46 + def follow_tag(%User{} = user, %Tag{} = tag) do 47 + %TagFollow{} 48 + |> TagFollow.changeset(%{user_id: user.id, tag_id: tag.id}) 49 + |> Repo.insert() 50 + |> case do 51 + {:ok, tag_follow} -> 52 + broadcast_tag_follow_event(tag_follow, :tag_followed) 53 + {:ok, tag_follow} 54 + error -> error 55 + end 56 + end 57 + 58 + @doc """ 59 + Unfollows a tag for a user. 60 + """ 61 + def unfollow_tag(%User{} = user, %Tag{} = tag) do 62 + from(tf in TagFollow, where: tf.user_id == ^user.id and tf.tag_id == ^tag.id) 63 + |> Repo.one() 64 + |> case do 65 + nil -> {:error, :not_found} 66 + tag_follow -> 67 + Repo.delete(tag_follow) 68 + |> case do 69 + {:ok, _} = result -> 70 + broadcast_tag_follow_event(tag_follow, :tag_unfollowed) 71 + result 72 + error -> error 73 + end 74 + end 75 + end 76 + 77 + @doc """ 78 + Checks if a user follows a tag. 79 + """ 80 + def follows_tag?(%User{} = user, %Tag{} = tag) do 81 + Repo.exists?(from tf in TagFollow, where: tf.user_id == ^user.id and tf.tag_id == ^tag.id) 82 + end 83 + 84 + # Notification functions 85 + 86 + @doc """ 87 + Gets all unread notifications for a user from ETS cache. 88 + """ 89 + def get_unread_notifications(%User{} = user) do 90 + case :ets.lookup(@notifications_table, user.id) do 91 + [{_, notifications}] -> notifications 92 + [] -> [] 93 + end 94 + end 95 + 96 + @doc """ 97 + Lists all notifications for a user from the database. 98 + Includes both read and unread notifications. 99 + """ 100 + def list_user_notifications(%User{} = user, limit \\ 50) do 101 + Notification 102 + |> where([n], n.user_id == ^user.id) 103 + |> order_by([n], desc: n.inserted_at) 104 + |> limit(^limit) 105 + |> Repo.all() 106 + |> Repo.preload([:bookmark, :tag]) 107 + end 108 + 109 + @doc """ 110 + Lists only unread notifications for a user from the database. 111 + """ 112 + def list_unread_notifications(%User{} = user) do 113 + Notification 114 + |> where([n], n.user_id == ^user.id and n.read == false) 115 + |> order_by([n], desc: n.inserted_at) 116 + |> Repo.all() 117 + |> Repo.preload([:bookmark, :tag]) 118 + end 119 + 120 + @doc """ 121 + Marks a notification as read. 122 + """ 123 + def mark_notification_read(%Notification{} = notification) do 124 + notification 125 + |> Notification.changeset(%{read: true}) 126 + |> Repo.update() 127 + |> case do 128 + {:ok, updated_notification} -> 129 + # Update ETS cache 130 + update_unread_notifications_cache(updated_notification, :remove) 131 + {:ok, updated_notification} 132 + error -> error 133 + end 134 + end 135 + 136 + @doc """ 137 + Marks all notifications as read for a user. 138 + """ 139 + def mark_all_notifications_read(%User{} = user) do 140 + {count, _} = 141 + Notification 142 + |> where([n], n.user_id == ^user.id and n.read == false) 143 + |> Repo.update_all(set: [read: true]) 144 + 145 + # Clear ETS cache for this user 146 + :ets.insert(@notifications_table, {user.id, []}) 147 + 148 + {:ok, count} 149 + end 150 + 151 + @doc """ 152 + Creates a notification for a bookmark with a tag. 153 + This will create notifications for all users following the tag. 154 + """ 155 + def create_tag_bookmark_notifications(%Bookmark{} = bookmark, %Tag{} = tag) do 156 + # Get all users following this tag 157 + followers = list_tag_followers(tag) 158 + 159 + # Create notifications for each follower 160 + Enum.each(followers, fn user -> 161 + attrs = %{ 162 + user_id: user.id, 163 + read: false, 164 + notification_type: "new_bookmark_with_followed_tag", 165 + data: %{ 166 + bookmark_title: bookmark.title, 167 + tag_name: tag.name 168 + }, 169 + bookmark_id: bookmark.id, 170 + tag_id: tag.id 171 + } 172 + 173 + %Notification{} 174 + |> Notification.changeset(attrs) 175 + |> Repo.insert() 176 + |> case do 177 + {:ok, notification} -> 178 + # Update ETS cache 179 + update_unread_notifications_cache(notification, :add) 180 + # Broadcast event 181 + PubSub.broadcast( 182 + Bookmarker.PubSub, 183 + "user:#{user.id}:notifications", 184 + {:new_notification, notification} 185 + ) 186 + {:error, _} -> nil 187 + end 188 + end) 189 + 190 + :ok 191 + end 192 + 193 + @doc """ 194 + Truncates old notifications to keep the table size manageable. 195 + Removes notifications older than @max_notification_days. 196 + """ 197 + def truncate_old_notifications do 198 + cutoff_date = NaiveDateTime.utc_now() |> NaiveDateTime.add(-@max_notification_days * 24 * 60 * 60) 199 + 200 + {count, _} = 201 + Notification 202 + |> where([n], n.inserted_at < ^cutoff_date) 203 + |> Repo.delete_all() 204 + 205 + {:ok, count} 206 + end 207 + 208 + # Private functions 209 + 210 + defp broadcast_tag_follow_event(tag_follow, event) do 211 + PubSub.broadcast( 212 + Bookmarker.PubSub, 213 + "tag_follows", 214 + {event, tag_follow} 215 + ) 216 + end 217 + 218 + defp update_unread_notifications_cache(notification, action) do 219 + case :ets.lookup(@notifications_table, notification.user_id) do 220 + [{user_id, notifications}] -> 221 + updated_notifications = 222 + case action do 223 + :add -> [notification | notifications] 224 + :remove -> Enum.reject(notifications, &(&1.id == notification.id)) 225 + end 226 + :ets.insert(@notifications_table, {user_id, updated_notifications}) 227 + [] when action == :add -> 228 + :ets.insert(@notifications_table, {notification.user_id, [notification]}) 229 + [] -> :ok 230 + end 231 + end 232 + end
+23
lib/bookmarker/notifications/notification.ex
··· 1 + defmodule Bookmarker.Notifications.Notification do 2 + use Ecto.Schema 3 + import Ecto.Changeset 4 + 5 + schema "notifications" do 6 + field :read, :boolean, default: false 7 + field :notification_type, :string 8 + field :data, :map 9 + 10 + belongs_to :user, Bookmarker.Accounts.User 11 + belongs_to :bookmark, Bookmarker.Bookmarks.Bookmark 12 + belongs_to :tag, Bookmarker.Tags.Tag 13 + 14 + timestamps() 15 + end 16 + 17 + @doc false 18 + def changeset(notification, attrs) do 19 + notification 20 + |> cast(attrs, [:user_id, :read, :notification_type, :data, :bookmark_id, :tag_id]) 21 + |> validate_required([:user_id, :read, :notification_type, :data]) 22 + end 23 + end
+81
lib/bookmarker/notifications_cache.ex
··· 1 + defmodule Bookmarker.NotificationsCache do 2 + @moduledoc """ 3 + ETS-based cache for user notifications. 4 + """ 5 + 6 + use GenServer 7 + alias Bookmarker.Notifications 8 + alias Bookmarker.Accounts 9 + alias Phoenix.PubSub 10 + 11 + @notifications_table :unread_notifications_cache 12 + 13 + # Client API 14 + 15 + def start_link(_opts) do 16 + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) 17 + end 18 + 19 + # Server callbacks 20 + 21 + @impl true 22 + def init(:ok) do 23 + # Create ETS table 24 + :ets.new(@notifications_table, [:set, :named_table, :public, read_concurrency: true]) 25 + 26 + # Initialize cache with data from the database 27 + initialize_cache() 28 + 29 + # Subscribe to notification events 30 + PubSub.subscribe(Bookmarker.PubSub, "tag_follows") 31 + 32 + # Schedule periodic cleanup of old notifications 33 + schedule_cleanup() 34 + 35 + {:ok, %{}} 36 + end 37 + 38 + @impl true 39 + def handle_info({:tag_followed, tag_follow}, state) do 40 + # We don't need to do anything special when a tag is followed 41 + {:noreply, state} 42 + end 43 + 44 + @impl true 45 + def handle_info({:tag_unfollowed, _tag_follow}, state) do 46 + # We don't need to do anything special when a tag is unfollowed 47 + {:noreply, state} 48 + end 49 + 50 + @impl true 51 + def handle_info(:cleanup_notifications, state) do 52 + # Truncate old notifications 53 + Notifications.truncate_old_notifications() 54 + 55 + # Schedule the next cleanup 56 + schedule_cleanup() 57 + 58 + {:noreply, state} 59 + end 60 + 61 + @impl true 62 + def handle_info(_, state), do: {:noreply, state} 63 + 64 + # Private functions 65 + 66 + defp initialize_cache do 67 + # Get all users 68 + users = Accounts.list_users() 69 + 70 + # For each user, load their unread notifications into the cache 71 + Enum.each(users, fn user -> 72 + unread_notifications = Notifications.list_unread_notifications(user) 73 + :ets.insert(@notifications_table, {user.id, unread_notifications}) 74 + end) 75 + end 76 + 77 + defp schedule_cleanup do 78 + # Run cleanup once a day 79 + Process.send_after(self(), :cleanup_notifications, 24 * 60 * 60 * 1000) 80 + end 81 + end
+20
lib/bookmarker/tags/tag_follow.ex
··· 1 + defmodule Bookmarker.Tags.TagFollow do 2 + use Ecto.Schema 3 + import Ecto.Changeset 4 + 5 + @primary_key false 6 + schema "tag_follows" do 7 + belongs_to :user, Bookmarker.Accounts.User 8 + belongs_to :tag, Bookmarker.Tags.Tag 9 + 10 + timestamps() 11 + end 12 + 13 + @doc false 14 + def changeset(tag_follow, attrs) do 15 + tag_follow 16 + |> cast(attrs, [:user_id, :tag_id]) 17 + |> validate_required([:user_id, :tag_id]) 18 + |> unique_constraint([:user_id, :tag_id]) 19 + end 20 + end
+10
lib/bookmarker_web/components/layouts/app.html.heex
··· 19 19 > 20 20 + new bookmark 21 21 </a> 22 + <%= if @current_user do %> 23 + <div class="flex items-center"> 24 + <.live_component module={BookmarkerWeb.NotificationComponent} id="notifications" current_user={@current_user} /> 25 + </div> 26 + <% end %> 22 27 </div> 23 28 <button class="sm:hidden text-gray-800 dark:text-gray-200" id="mobile-menu-button"> 24 29 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6"> ··· 40 45 > 41 46 + new bookmark 42 47 </a> 48 + <%= if @current_user do %> 49 + <a href="#" class="py-2 hover:text-indigo-600 dark:hover:text-indigo-400 transition"> 50 + Notifications 51 + </a> 52 + <% end %> 43 53 </nav> 44 54 </div> 45 55 </header>
+75
lib/bookmarker_web/components/notification_component.ex
··· 1 + defmodule BookmarkerWeb.NotificationComponent do 2 + use BookmarkerWeb, :live_component 3 + 4 + alias Bookmarker.Notifications 5 + alias Bookmarker.Accounts.User 6 + alias Phoenix.PubSub 7 + 8 + @impl true 9 + def mount(socket) do 10 + {:ok, socket} 11 + end 12 + 13 + @impl true 14 + def update(%{current_user: %User{} = user} = assigns, socket) do 15 + # Subscribe to this user's notification events 16 + if connected?(socket) do 17 + PubSub.subscribe(Bookmarker.PubSub, "user:#{user.id}:notifications") 18 + end 19 + 20 + unread_notifications = Notifications.get_unread_notifications(user) 21 + 22 + {:ok, 23 + socket 24 + |> assign(assigns) 25 + |> assign(:unread_notifications, unread_notifications) 26 + |> assign(:show_dropdown, false)} 27 + end 28 + 29 + # This function will be called from the LiveView to handle messages for us 30 + def handle_notification_message(id, notification) do 31 + send_update(__MODULE__, id: id, new_notification: notification) 32 + end 33 + 34 + # This is a special update function to handle messages sent via send_update 35 + @impl true 36 + def update(%{new_notification: notification} = _assigns, socket) do 37 + updated_notifications = [notification | socket.assigns.unread_notifications] 38 + 39 + {:ok, assign(socket, :unread_notifications, updated_notifications)} 40 + end 41 + 42 + @impl true 43 + def handle_event("toggle-dropdown", _, socket) do 44 + {:noreply, assign(socket, :show_dropdown, !socket.assigns.show_dropdown)} 45 + end 46 + 47 + @impl true 48 + def handle_event("mark-read", %{"id" => notification_id}, socket) do 49 + notification = Enum.find(socket.assigns.unread_notifications, &(&1.id == String.to_integer(notification_id))) 50 + 51 + if notification do 52 + Notifications.mark_notification_read(notification) 53 + end 54 + 55 + {:noreply, socket} 56 + end 57 + 58 + @impl true 59 + def handle_event("mark-all-read", _, socket) do 60 + Notifications.mark_all_notifications_read(socket.assigns.current_user) 61 + 62 + {:noreply, assign(socket, :unread_notifications, [])} 63 + end 64 + 65 + @impl true 66 + def handle_info({:new_notification, notification}, socket) do 67 + updated_notifications = [notification | socket.assigns.unread_notifications] 68 + 69 + {:noreply, assign(socket, :unread_notifications, updated_notifications)} 70 + end 71 + 72 + defp format_notification(%{notification_type: "new_bookmark_with_followed_tag"} = notification) do 73 + "New bookmark '#{notification.data["bookmark_title"]}' with tag '#{notification.data["tag_name"]}'" 74 + end 75 + end
+64
lib/bookmarker_web/components/notification_component.html.heex
··· 1 + <div class="relative"> 2 + <button 3 + type="button" 4 + phx-click="toggle-dropdown" 5 + phx-target={@myself} 6 + class="inline-flex items-center px-3 py-2 border border-transparent shadow-sm text-sm leading-4 font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" 7 + > 8 + <.icon name="hero-bell" class="h-5 w-5 mr-1" /> 9 + <%= if length(@unread_notifications) > 0 do %> 10 + <span class="bg-red-500 text-white text-xs rounded-full px-2 py-1 ml-1"> 11 + <%= length(@unread_notifications) %> 12 + </span> 13 + <% end %> 14 + </button> 15 + 16 + <%= if @show_dropdown do %> 17 + <div class="origin-top-right absolute right-0 mt-2 w-80 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-50"> 18 + <div class="py-1"> 19 + <div class="px-4 py-2 border-b flex justify-between items-center"> 20 + <h3 class="text-lg font-medium text-gray-900">Notifications</h3> 21 + <%= if length(@unread_notifications) > 0 do %> 22 + <button 23 + phx-click="mark-all-read" 24 + phx-target={@myself} 25 + class="text-sm text-indigo-600 hover:text-indigo-900" 26 + > 27 + Mark all as read 28 + </button> 29 + <% end %> 30 + </div> 31 + <div class="max-h-64 overflow-y-auto"> 32 + <%= if length(@unread_notifications) == 0 do %> 33 + <div class="px-4 py-2 text-sm text-gray-500"> 34 + No new notifications 35 + </div> 36 + <% end %> 37 + 38 + <%= for notification <- @unread_notifications do %> 39 + <div class="px-4 py-2 hover:bg-gray-100 flex justify-between items-center"> 40 + <div class="w-full"> 41 + <div class="text-sm text-gray-700"> 42 + <%= format_notification(notification) %> 43 + </div> 44 + <div class="text-xs text-gray-500"> 45 + <%= Timex.format!(notification.inserted_at, "{relative}", :relative) %> 46 + </div> 47 + </div> 48 + <div class="ml-2"> 49 + <button 50 + phx-click="mark-read" 51 + phx-value-id={notification.id} 52 + phx-target={@myself} 53 + class="text-indigo-600 hover:text-indigo-900" 54 + > 55 + <.icon name="hero-check-circle" class="h-5 w-5" /> 56 + </button> 57 + </div> 58 + </div> 59 + <% end %> 60 + </div> 61 + </div> 62 + </div> 63 + <% end %> 64 + </div>
+25
lib/bookmarker_web/live/main_layout_live.ex
··· 1 + defmodule BookmarkerWeb.MainLayoutLive do 2 + use BookmarkerWeb, :live_view 3 + 4 + alias Phoenix.PubSub 5 + alias BookmarkerWeb.NotificationComponent 6 + 7 + @impl true 8 + def mount(_params, _session, socket) do 9 + current_user = socket.assigns[:current_user] 10 + 11 + if connected?(socket) && current_user do 12 + PubSub.subscribe(Bookmarker.PubSub, "user:#{current_user.id}:notifications") 13 + end 14 + 15 + {:ok, socket} 16 + end 17 + 18 + @impl true 19 + def handle_info({:new_notification, notification}, socket) do 20 + # Forward notification to the component 21 + NotificationComponent.handle_notification_message("notifications", notification) 22 + 23 + {:noreply, socket} 24 + end 25 + end
+51
lib/bookmarker_web/live/tag_live/show.ex
··· 4 4 alias Bookmarker.Bookmarks 5 5 alias Bookmarker.Cache 6 6 alias Bookmarker.Accounts 7 + alias Bookmarker.Tags 8 + alias Bookmarker.Notifications 7 9 8 10 @impl true 9 11 def mount(%{"tag_name" => tag_name}, _session, socket) do ··· 17 19 # For demo purposes, let's get the first user or create one 18 20 user = get_or_create_demo_user() 19 21 22 + # Get the tag record 23 + tag = Tags.get_tag_by_name(tag_name) 24 + 25 + # Check if the user follows this tag 26 + follows_tag = if user && tag, do: Notifications.follows_tag?(user, tag), else: false 27 + 20 28 {:ok, 21 29 socket 22 30 |> assign(:tag_name, tag_name) 31 + |> assign(:tag, tag) 23 32 |> assign(:bookmarks, bookmarks) 24 33 |> assign(:current_user, user) 34 + |> assign(:follows_tag, follows_tag) 25 35 |> assign(:page_title, "Bookmarks tagged with #{tag_name}")} 26 36 end 27 37 28 38 @impl true 39 + def handle_event("follow_tag", _, socket) do 40 + %{current_user: user, tag: tag} = socket.assigns 41 + 42 + case Notifications.follow_tag(user, tag) do 43 + {:ok, _tag_follow} -> 44 + {:noreply, assign(socket, :follows_tag, true)} 45 + {:error, _changeset} -> 46 + {:noreply, socket} 47 + end 48 + end 49 + 50 + @impl true 51 + def handle_event("unfollow_tag", _, socket) do 52 + %{current_user: user, tag: tag} = socket.assigns 53 + 54 + case Notifications.unfollow_tag(user, tag) do 55 + {:ok, _tag_follow} -> 56 + {:noreply, assign(socket, :follows_tag, false)} 57 + {:error, _} -> 58 + {:noreply, socket} 59 + end 60 + end 61 + 62 + @impl true 29 63 def handle_info({:bookmark_created, bookmark}, socket) do 30 64 # Check if the bookmark has the current tag 31 65 if has_tag?(bookmark, socket.assigns.tag_name) do ··· 67 101 |> Enum.reject(fn b -> b.id == bookmark.id end) 68 102 69 103 {:noreply, assign(socket, :bookmarks, updated_bookmarks)} 104 + end 105 + 106 + @impl true 107 + def handle_info({:tag_followed, _tag_follow}, socket) do 108 + {:noreply, assign(socket, :follows_tag, true)} 109 + end 110 + 111 + @impl true 112 + def handle_info({:tag_unfollowed, _tag_follow}, socket) do 113 + {:noreply, assign(socket, :follows_tag, false)} 114 + end 115 + 116 + @impl true 117 + def handle_info({:new_notification, _notification}, socket) do 118 + # We don't need to do anything with notifications in this LiveView 119 + # They're handled by the notification component 120 + {:noreply, socket} 70 121 end 71 122 72 123 defp has_tag?(bookmark, tag_name) do
+26
lib/bookmarker_web/live/tag_live/show.html.heex
··· 14 14 <%= @tag_name %> 15 15 </span> 16 16 </h1> 17 + 18 + <%= if @current_user && @tag do %> 19 + <div> 20 + <%= if @follows_tag do %> 21 + <button 22 + phx-click="unfollow_tag" 23 + class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" 24 + > 25 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor"> 26 + <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" /> 27 + </svg> 28 + Unfollow Tag 29 + </button> 30 + <% else %> 31 + <button 32 + phx-click="follow_tag" 33 + class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" 34 + > 35 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor"> 36 + <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" /> 37 + </svg> 38 + Follow Tag 39 + </button> 40 + <% end %> 41 + </div> 42 + <% end %> 17 43 </div> 18 44 19 45 <div class="bg-white shadow overflow-hidden sm:rounded-md">
+12
lib/bookmarker_web/router.ex
··· 14 14 plug :accepts, ["json"] 15 15 end 16 16 17 + pipeline :rss do 18 + plug :accepts, ["xml"] 19 + plug :put_format, "xml" 20 + end 21 + 17 22 scope "/", BookmarkerWeb do 18 23 pipe_through :browser 19 24 ··· 25 30 live "/tags/:tag_name", TagLive.Show 26 31 live "/users/:username", UserLive.Show 27 32 live "/users/:username/tags/:tag_name", UserLive.TagShow 33 + end 34 + 35 + scope "/", BookmarkerWeb do 36 + pipe_through :rss 37 + 38 + get "/rss/tags/:tag_name", RssController, :tag 39 + get "/rss/users/:username/tags/:tag_name", RssController, :user_tag 28 40 end 29 41 30 42 # Other scopes may use custom stacks.
+2 -1
mix.exs
··· 58 58 {:dns_cluster, "~> 0.1.1"}, 59 59 {:bandit, "~> 1.5"}, 60 60 {:req, "~> 0.4"}, 61 - {:websockex, "~> 0.4"} 61 + {:websockex, "~> 0.4"}, 62 + {:timex, "~> 3.7"} 62 63 ] 63 64 end 64 65
+11
mix.lock
··· 1 1 %{ 2 2 "bandit": {:hex, :bandit, "1.6.8", "be6fcbe01a74e6cba42ae35f4085acaeae9b2d8d360c0908d0b9addbc2811e47", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "4fc08c8d4733735d175a007ecb25895e84d09292b0180a2e9f16948182c88b6e"}, 3 3 "castore": {:hex, :castore, "1.0.12", "053f0e32700cbec356280c0e835df425a3be4bc1e0627b714330ad9d0f05497f", [:mix], [], "hexpm", "3dca286b2186055ba0c9449b4e95b97bf1b57b47c1f2644555879e659960c224"}, 4 + "certifi": {:hex, :certifi, "2.14.0", "ed3bef654e69cde5e6c022df8070a579a79e8ba2368a00acf3d75b82d9aceeed", [:rebar3], [], "hexpm", "ea59d87ef89da429b8e905264fdec3419f84f2215bb3d81e07a18aac919026c3"}, 5 + "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, 4 6 "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, 5 7 "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 6 8 "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, ··· 12 14 "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, 13 15 "floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"}, 14 16 "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, 17 + "hackney": {:hex, :hackney, "1.23.0", "55cc09077112bcb4a69e54be46ed9bc55537763a96cd4a80a221663a7eafd767", [:rebar3], [{:certifi, "~> 2.14.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "6cd1c04cd15c81e5a493f167b226a15f0938a84fc8f0736ebe4ddcab65c0b44e"}, 15 18 "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]}, 16 19 "hpax": {:hex, :hpax, "1.0.2", "762df951b0c399ff67cc57c3995ec3cf46d696e41f0bba17da0518d94acd4aac", [:mix], [], "hexpm", "2f09b4c1074e0abd846747329eaa26d535be0eb3d189fa69d812bfb8bfefd32f"}, 20 + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 17 21 "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 22 + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 18 23 "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, 24 + "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, 19 25 "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, 20 26 "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 21 27 "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 28 + "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, 22 29 "phoenix": {:hex, :phoenix, "1.7.20", "6bababaf27d59f5628f9b608de902a021be2cecefb8231e1dbdc0a2e2e480e9b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "6be2ab98302e8784a31829e0d50d8bdfa81a23cd912c395bafd8b8bfb5a086c2"}, 23 30 "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.3", "f686701b0499a07f2e3b122d84d52ff8a31f5def386e03706c916f6feddf69ef", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "909502956916a657a197f94cc1206d9a65247538de8a5e186f7537c895d95764"}, 24 31 "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, ··· 31 38 "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, 32 39 "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, 33 40 "req": {:hex, :req, "0.5.8", "50d8d65279d6e343a5e46980ac2a70e97136182950833a1968b371e753f6a662", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "d7fc5898a566477e174f26887821a3c5082b243885520ee4b45555f5d53f40ef"}, 41 + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, 34 42 "swoosh": {:hex, :swoosh, "1.18.2", "41279e8449b65d14b571b66afe9ab352c3b0179291af8e5f4ad9207f489ad11a", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "032fcb2179f6d4e3b90030514ddc8d3946d8b046be939d121db480ca78adbc38"}, 35 43 "tailwind": {:hex, :tailwind, "0.3.1", "a89d2835c580748c7a975ad7dd3f2ea5e63216dc16d44f9df492fbd12c094bed", [:mix], [], "hexpm", "98a45febdf4a87bc26682e1171acdedd6317d0919953c353fcd1b4f9f4b676a2"}, 36 44 "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 37 45 "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, 38 46 "telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"}, 39 47 "thousand_island": {:hex, :thousand_island, "1.3.11", "b68f3e91f74d564ae20b70d981bbf7097dde084343c14ae8a33e5b5fbb3d6f37", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "555c18c62027f45d9c80df389c3d01d86ba11014652c00be26e33b1b64e98d29"}, 48 + "timex": {:hex, :timex, "3.7.11", "bb95cb4eb1d06e27346325de506bcc6c30f9c6dea40d1ebe390b262fad1862d1", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.20", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8b9024f7efbabaf9bd7aa04f65cf8dcd7c9818ca5737677c7b76acbc6a94d1aa"}, 49 + "tzdata": {:hex, :tzdata, "1.1.3", "b1cef7bb6de1de90d4ddc25d33892b32830f907e7fc2fccd1e7e22778ab7dfbc", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d4ca85575a064d29d4e94253ee95912edfb165938743dbf002acdf0dcecb0c28"}, 50 + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 40 51 "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 41 52 "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, 42 53 "websockex": {:hex, :websockex, "0.4.3", "92b7905769c79c6480c02daacaca2ddd49de936d912976a4d3c923723b647bf0", [:mix], [], "hexpm", "95f2e7072b85a3a4cc385602d42115b73ce0b74a9121d0d6dbbf557645ac53e4"},
+16
priv/repo/migrations/20250329010133_create_tag_follows.exs
··· 1 + defmodule Bookmarker.Repo.Migrations.CreateTagFollows do 2 + use Ecto.Migration 3 + 4 + def change do 5 + create table(:tag_follows, primary_key: false) do 6 + add :user_id, references(:users, on_delete: :delete_all), null: false 7 + add :tag_id, references(:tags, on_delete: :delete_all), null: false 8 + 9 + timestamps() 10 + end 11 + 12 + create index(:tag_follows, [:user_id]) 13 + create index(:tag_follows, [:tag_id]) 14 + create unique_index(:tag_follows, [:user_id, :tag_id]) 15 + end 16 + end
+24
priv/repo/migrations/20250329010140_create_notifications.exs
··· 1 + defmodule Bookmarker.Repo.Migrations.CreateNotifications do 2 + use Ecto.Migration 3 + 4 + def change do 5 + create table(:notifications) do 6 + add :user_id, references(:users, on_delete: :delete_all), null: false 7 + add :read, :boolean, default: false, null: false 8 + add :notification_type, :string, null: false 9 + add :data, :map, null: false 10 + add :bookmark_id, references(:bookmarks, on_delete: :nilify_all) 11 + add :tag_id, references(:tags, on_delete: :nilify_all) 12 + 13 + timestamps() 14 + end 15 + 16 + create index(:notifications, [:user_id]) 17 + create index(:notifications, [:read]) 18 + create index(:notifications, [:notification_type]) 19 + create index(:notifications, [:user_id, :read]) 20 + create index(:notifications, [:bookmark_id]) 21 + create index(:notifications, [:tag_id]) 22 + create index(:notifications, [:inserted_at]) 23 + end 24 + end