From b55e03f6bc27d697486fa78f82236fddf242b762 Mon Sep 17 00:00:00 2001 From: Hannah Purcell Date: Tue, 10 Dec 2024 19:20:49 -0500 Subject: [PATCH] feat: Set up detours channels --- assets/src/hooks/useDetours.ts | 244 ++++++++++++++++++++++ lib/skate/detours/detours.ex | 98 ++++++++- lib/skate_web/channels/detours_channel.ex | 62 ++++++ lib/skate_web/channels/user_socket.ex | 1 + 4 files changed, 401 insertions(+), 4 deletions(-) create mode 100644 assets/src/hooks/useDetours.ts create mode 100644 lib/skate_web/channels/detours_channel.ex diff --git a/assets/src/hooks/useDetours.ts b/assets/src/hooks/useDetours.ts new file mode 100644 index 000000000..c3e89f482 --- /dev/null +++ b/assets/src/hooks/useDetours.ts @@ -0,0 +1,244 @@ +import { Channel, Socket } from "phoenix" +import { SimpleDetour } from "../models/detoursList" +import { useEffect, useState } from "react" +import { reload } from "../models/browser" +import { userUuid } from "../util/userUuid" +import { RouteId } from "../schedule" + +interface DetoursMap { + [key: number]: SimpleDetour +} + +const subscribe = ( + socket: Socket, + topic: string, + initializeChannel: React.Dispatch>, + handleDrafted: ((data: SimpleDetour) => void) | undefined, + handleActivated: ((data: SimpleDetour) => void) | undefined, + handleDeactivated: ((data: SimpleDetour) => void) | undefined +): Channel => { + const channel = socket.channel(topic) + + handleDrafted && + channel.on("drafted", ({ data: data }) => handleDrafted(data)) + handleActivated && + channel.on("activated", ({ data: data }) => handleActivated(data)) + handleDeactivated && + channel.on("deactivated", ({ data: data }) => handleDeactivated(data)) + channel.on("auth_expired", reload) + + channel + .join() + .receive("ok", ({ data: data }: { data: SimpleDetour[] }) => { + const detoursMap = data.reduce( + (acc, detour) => ({ ...acc, [detour.id]: detour }), + {} + ) + initializeChannel(detoursMap) + }) + + .receive("error", ({ reason }) => { + if (reason === "not_authenticated") { + reload() + } else { + // eslint-disable-next-line no-console + console.error(`joining topic ${topic} failed`, reason) + } + }) + .receive("timeout", reload) + + return channel +} + +// This is to refresh the Detours List page. We need all active detours +export const useActiveDetours = (socket: Socket | undefined) => { + const topic = "detours:active" + const [activeDetours, setActiveDetours] = useState({}) + + const handleActivated = (data: SimpleDetour) => { + setActiveDetours((activeDetours) => ({ ...activeDetours, [data.id]: data })) + } + + const handleDeactivated = (data: SimpleDetour) => { + setActiveDetours((activeDetours) => { + delete activeDetours[data.id] + return activeDetours + }) + } + + useEffect(() => { + let channel: Channel | undefined + if (socket) { + channel = subscribe( + socket, + topic, + setActiveDetours, + undefined, + handleActivated, + handleDeactivated + ) + } + + return () => { + if (channel !== undefined) { + channel.leave() + channel = undefined + } + } + }, [socket]) + return activeDetours +} + +// This is to refresh the Detours List page, past detours section +export const usePastDetours = (socket: Socket | undefined) => { + const topic = "detours:past" + const [pastDetours, setPastDetours] = useState({}) + + const handleDeactivated = (data: SimpleDetour) => { + setPastDetours((pastDetours) => ({ ...pastDetours, [data.id]: data })) + } + + useEffect(() => { + let channel: Channel | undefined + if (socket) { + channel = subscribe( + socket, + topic, + setPastDetours, + undefined, + undefined, + handleDeactivated + ) + } + + return () => { + if (channel !== undefined) { + channel.leave() + channel = undefined + } + } + }, [socket]) + return pastDetours +} + +// This is to refresh the Detours List page, just the current user drafts +export const useDraftDetours = (socket: Socket | undefined) => { + const topic = "detours:draft:" + userUuid() + const [draftDetours, setDraftDetours] = useState({}) + + const handleDrafted = (data: SimpleDetour) => { + setDraftDetours((draftDetours) => ({ ...draftDetours, [data.id]: data })) + } + + const handleActivated = (data: SimpleDetour) => { + setDraftDetours((draftDetours) => { + delete draftDetours[data.id] + return draftDetours + }) + } + + useEffect(() => { + let channel: Channel | undefined + if (socket) { + channel = subscribe( + socket, + topic, + setDraftDetours, + handleDrafted, + handleActivated, + undefined + ) + } + + return () => { + if (channel !== undefined) { + channel.leave() + channel = undefined + } + } + }, [socket]) + return draftDetours +} + +interface DetoursMapByRoute { + [key: string]: DetoursMap +} + +const subscribeByRoute = ( + socket: Socket, + topic: string, + routeId: string, + setDetours: React.Dispatch> +): Channel => { + const channel = socket.channel(topic) + + channel.on("activated", ({ data: data }) => { + setDetours((activeDetours) => ({ + ...activeDetours, + [routeId]: { ...activeDetours[routeId], [data.id]: data }, + })) + }) + channel.on("deactivated", ({ data: data }) => { + setDetours((activeDetours) => { + delete activeDetours[routeId][data.id] + return activeDetours + }) + }) + channel.on("auth_expired", reload) + + channel + .join() + .receive("ok", ({ data: data }: { data: SimpleDetour[] }) => { + const detoursMap = { + [routeId]: data.reduce( + (acc, detour) => ({ ...acc, [detour.id]: detour }), + {} + ), + } + setDetours(detoursMap) + }) + + .receive("error", ({ reason }) => { + if (reason === "not_authenticated") { + reload() + } else { + // eslint-disable-next-line no-console + console.error(`joining topic ${topic} failed`, reason) + } + }) + .receive("timeout", reload) + + return channel +} + +// This is to refresh the Route Ladders +export const useActiveDetoursByRoute = ( + socket: Socket | undefined, + routeIds: RouteId[] +) => { + const baseTopic = "detours:active:" + const [activeDetours, setActiveDetours] = useState({}) + + useEffect(() => { + let channel: Channel | undefined + if (socket) { + routeIds.forEach( + (routeId) => + (channel = subscribeByRoute( + socket, + baseTopic, + routeId, + setActiveDetours + )) + ) + } + + return () => { + if (channel !== undefined) { + channel.leave() + channel = undefined + } + } + }, [socket]) + return activeDetours +} diff --git a/lib/skate/detours/detours.ex b/lib/skate/detours/detours.ex index 12e34396e..c4b302a8c 100644 --- a/lib/skate/detours/detours.ex +++ b/lib/skate/detours/detours.ex @@ -28,6 +28,22 @@ defmodule Skate.Detours.Detours do |> Repo.all() end + @doc """ + Returns the list of detours by route id with author, sorted by updated_at + + ## Examples + + iex> active_detours_by_route() + [%Detour{}, ...] + """ + def active_detours_by_route(route_id) do + list_detours() + |> Enum.filter(fn detour -> + categorize_detour(detour) == :active and get_detour_route(detour) == route_id + end) + |> Enum.map(fn detour -> db_detour_to_detour(detour) end) + end + @doc """ Returns the detours grouped by active, draft, and past. @@ -114,9 +130,16 @@ defmodule Skate.Detours.Detours do def categorize_detour(%{state: %{"value" => %{"Detour Drawing" => "Past"}}}, _user_id), do: :past - def categorize_detour(_detour_context, nil = _user_id), do: :draft - def categorize_detour(%{author_id: author_id}, user_id) when author_id == user_id, do: :draft - def categorize_detour(_, _), do: nil + @doc """ + Takes a `Skate.Detours.Db.Detour` struct and a `Skate.Settings.Db.User` id + and returns a `t:detour_type/0` based on the state of the detour. + + otherwise returns `nil` if it is a draft but does not belong to the provided + user + """ + @spec get_detour_route(detour :: map()) :: String.t() + def get_detour_route(%{state: %{"context" => %{"route" => %{"name" => route_name}}}}), + do: route_name @doc """ Gets a single detour. @@ -214,7 +237,11 @@ defmodule Skate.Detours.Detours do case detour_db_result do {:ok, %Detour{} = new_record} -> - send_notification(new_record, previous_record, author_id) + new_record + |> categorize_detour() + |> broadcast_detour(new_record, author_id) + + send_notification(new_record, previous_record) _ -> nil @@ -223,6 +250,69 @@ defmodule Skate.Detours.Detours do detour_db_result end + @spec broadcast_detour(detour_type(), Detour.t(), User.id()) :: nil + defp broadcast_detour(:draft, detour, author_id) do + author_uuid = + author_id + |> User.get_by_id!() + |> Map.get(:uuid) + + Phoenix.PubSub.broadcast( + Skate.PubSub, + "detours:draft:" <> author_uuid, + {:detour_drafted, db_detour_to_detour(detour)} + ) + end + + defp broadcast_detour(:active, detour, author_id) do + author_uuid = + author_id + |> User.get_by_id!() + |> Map.get(:uuid) + + route_id = get_detour_route(detour) + + Phoenix.PubSub.broadcast( + Skate.PubSub, + "detours:draft:" <> author_uuid, + {:detour_activated, db_detour_to_detour(detour)} + ) + + Phoenix.PubSub.broadcast( + Skate.PubSub, + "detours:active" <> route_id, + {:detour_activated, db_detour_to_detour(detour)} + ) + + Phoenix.PubSub.broadcast( + Skate.PubSub, + "detours:active", + {:detour_activated, db_detour_to_detour(detour)} + ) + end + + defp broadcast_detour(:past, detour, _author_id) do + route_id = get_detour_route(detour) + + Phoenix.PubSub.broadcast( + Skate.PubSub, + "detours:active" <> route_id, + {:detour_deactivated, db_detour_to_detour(detour)} + ) + + Phoenix.PubSub.broadcast( + Skate.PubSub, + "detours:active", + {:detour_deactivated, db_detour_to_detour(detour)} + ) + + Phoenix.PubSub.broadcast( + Skate.PubSub, + "detours:past", + {:detour_deactivated, db_detour_to_detour(detour)} + ) + end + @doc """ Retrieves a `Skate.Detours.Db.Detour` from the database by it's ID and then resolves the detour's category via `categorize_detour/2` diff --git a/lib/skate_web/channels/detours_channel.ex b/lib/skate_web/channels/detours_channel.ex new file mode 100644 index 000000000..6e9ee01c8 --- /dev/null +++ b/lib/skate_web/channels/detours_channel.ex @@ -0,0 +1,62 @@ +defmodule SkateWeb.DetoursChannel do + @moduledoc false + + use SkateWeb, :channel + use SkateWeb.AuthenticatedChannel + + alias Skate.Detours.Detours + + # Active + @impl SkateWeb.AuthenticatedChannel + def join_authenticated("detours:active", _message, socket) do + SkateWeb.Endpoint.subscribe("detours:active") + %{id: user_id} = Guardian.Phoenix.Socket.current_resource(socket) + detours = Detours.grouped_detours(user_id)[:active] + {:ok, %{data: detours}, socket} + end + + @impl SkateWeb.AuthenticatedChannel + def join_authenticated("detours:active" <> route_id, _message, socket) do + SkateWeb.Endpoint.subscribe("detours:active" <> route_id) + %{id: user_id} = Guardian.Phoenix.Socket.current_resource(socket) + + detours = Detours.active_detours_by_route(route_id) + {:ok, %{data: detours}, socket} + end + + # Past + @impl SkateWeb.AuthenticatedChannel + def join_authenticated("detours:past", _message, socket) do + SkateWeb.Endpoint.subscribe("detours:past") + %{id: user_id} = Guardian.Phoenix.Socket.current_resource(socket) + detours = Detours.grouped_detours(user_id)[:past] + {:ok, %{data: detours}, socket} + end + + # Draft + @impl SkateWeb.AuthenticatedChannel + def join_authenticated("detours:draft:" <> author_uuid, _message, socket) do + SkateWeb.Endpoint.subscribe("detours:draft:" <> author_uuid) + %{id: user_id} = Guardian.Phoenix.Socket.current_resource(socket) + detours = Detours.grouped_detours(user_id)[:draft] + {:ok, %{data: detours}, socket} + end + + @impl SkateWeb.AuthenticatedChannel + def handle_info_authenticated({:detour_activated, detour}, socket) do + :ok = push(socket, "activated", %{data: detour}) + {:noreply, socket} + end + + @impl SkateWeb.AuthenticatedChannel + def handle_info_authenticated({:detour_deactivated, detour}, socket) do + :ok = push(socket, "deactivated", %{data: detour}) + {:noreply, socket} + end + + @impl SkateWeb.AuthenticatedChannel + def handle_info_authenticated({:detour_drafted, detour}, socket) do + :ok = push(socket, "drafted", %{data: detour}) + {:noreply, socket} + end +end diff --git a/lib/skate_web/channels/user_socket.ex b/lib/skate_web/channels/user_socket.ex index b1a03456d..42e8f2bfc 100644 --- a/lib/skate_web/channels/user_socket.ex +++ b/lib/skate_web/channels/user_socket.ex @@ -10,6 +10,7 @@ defmodule SkateWeb.UserSocket do channel("train_vehicles:*", SkateWeb.TrainVehiclesChannel) channel("notifications", SkateWeb.NotificationsChannel) channel("alerts:*", SkateWeb.AlertsChannel) + channel("detours:*", SkateWeb.DetoursChannel) # Socket params are passed from the client and can # be used to verify and authenticate a user. After