this repo has no description

Door 1: Try doing it all in one swoop, Claude 3.7 sonnet (not thinking) #1

open opened by bobbby.online targeting door-number-one-a-do-it-all-approach

Door Number One#

We test out a "do it all in one swoop" with some established patterns approach.

Given light context to help it out.

Prompt:

I want to add an ability to follow a given tag and receive notifications when someone publishes a new link to it. For example, I should be able to follow "vtuber" and see all posts with the "vtuber" tag. This should be stored in postgres, but handled with ETS in the beginning. Once the user sees the notification, we write it as viewed to postgres and keep the old log of them there. The table will grow eventually, but we will simply implement a chronological truncation strategy since we dont care about keeping notifications long term.

This should be a relationship creating a new idea of following a given user in the postgres database. A user can have thousands of followers. This should be a join table by default to keep things slim.

Handle any domain logic through one master context that controls everything needed for these premises.

This should centralize around @user.ex @tag.ex @bookmark_tag.ex and it should be shown in the application as a tray with a dropdown that shows you each new link that user posted.
Labels

None yet.

Participants 1
AT URI
at://did:plc:6e6n5nhhy7s2zqr7wx4s6p52/sh.tangled.repo.pull/3lli62nyxqn22
+678 -1
Diff #0
+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 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 + 28 62 @impl true 29 63 def handle_info({:bookmark_created, bookmark}, socket) do 30 64 # Check if the bookmark has the current tag ··· 69 103 {:noreply, assign(socket, :bookmarks, updated_bookmarks)} 70 104 end 71 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} 121 + end 122 + 72 123 defp has_tag?(bookmark, tag_name) do 73 124 Enum.any?(bookmark.tags, fn tag -> tag.name == tag_name end) 74 125 end
+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 ··· 27 32 live "/users/:username/tags/:tag_name", UserLive.TagShow 28 33 end 29 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 40 + end 41 + 30 42 # Other scopes may use custom stacks. 31 43 # scope "/api", BookmarkerWeb do 32 44 # pipe_through :api
+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

Submissions

sign up or login to add to the discussion
bobbby.online submitted #0
merge conflicts detected
expand
  • lib/bookmarker/application.ex:16
  • lib/bookmarker/bookmarks.ex:7
  • lib/bookmarker/bookmarks.ex:7
  • lib/bookmarker/bookmarks.ex:7
  • lib/bookmarker/bookmarks.ex:7
  • lib/bookmarker/bookmarks.ex:7
  • lib/bookmarker_web/components/layouts/app.html.heex:19
  • lib/bookmarker_web/components/layouts/app.html.heex:19
  • lib/bookmarker_web/components/layouts/app.html.heex:19
  • lib/bookmarker_web/components/layouts/app.html.heex:19
  • lib/bookmarker_web/live/tag_live/show.ex:4
  • lib/bookmarker_web/live/tag_live/show.html.heex:14
  • lib/bookmarker_web/router.ex:14
  • mix.exs:58
  • mix.lock:1
  • mix.lock:1
  • mix.lock:1