defmodule PhoenixKitCRM.Web.InteractionsComponent do @moduledoc """ The Interactions / History tab for a contact: a reverse-chronological feed of interactions involving the contact, plus a composer to log a new one with a free-form-but-resolvable "involved parties" picker (CRM contacts + staff). """ use PhoenixKitWeb, :live_component use Gettext, backend: PhoenixKitCRM.Gettext require Logger import PhoenixKitCRM.Web.InteractionHelpers, only: [party_badge: 1] alias PhoenixKit.Modules.Storage alias PhoenixKit.Users.Auth alias PhoenixKitCRM.{Attachments, Contacts, Interactions, StaffLink} alias PhoenixKitCRM.Schemas.{Contact, Interaction} # Curated attachment allowlist — broad enough for real CRM attachments but # excludes inline-renderable script vectors (.html/.htm/.svg/.xml/.xhtml) that # could be served same-origin. Don't trust the browser-supplied content-type; # the extension is what core derives the stored/served mime from. @upload_accept ~w( .jpg .jpeg .png .gif .webp .bmp .tiff .heic .pdf .txt .csv .rtf .md .doc .docx .xls .xlsx .ppt .pptx .odt .ods .odp .zip .gz .tar .mp3 .wav .m4a .ogg .mp4 .mov .webm .mkv ) # Explicit size cap (LiveView's default is only 8 MB) — 25 MiB per file. @max_upload_size 26_214_400 @impl true def update(assigns, socket) do socket = assign(socket, assigns) offset = socket.assigns[:tz_offset] || 0 {:ok, socket |> assign_new(:staged_parties, fn -> [] end) |> assign_new(:staged_files, fn -> [] end) # Composer fields are controlled (kept in assigns) so re-renders triggered # by staging a party don't wipe what the user has typed. `c_occurred_at` # is the user's LOCAL wall-clock time (in their profile timezone); it's # converted to/from UTC at the storage boundary. (The party search box + # dropdown are owned entirely by core's SearchPicker JS hook — no server state.) |> assign_new(:c_type, fn -> "note" end) |> assign_new(:c_subject, fn -> "" end) |> assign_new(:c_body, fn -> "" end) |> assign_new(:c_occurred_at, fn -> local_now_str(offset) end) |> assign_new(:save_error, fn -> nil end) |> assign(:staff_enabled, StaffLink.enabled?()) |> assign(:storage_enabled, storage_enabled?()) |> maybe_allow_upload() |> maybe_reload_interactions()} end # Reload the feed only when the contact changes (mount / navigation) or the # host signals a refresh via `:refresh_token` (its PubSub-driven `send_update`). # An unrelated host re-render (e.g. the header avatar changing) re-passes the # contact struct but no new token, so we skip the timeline re-query. defp maybe_reload_interactions(socket) do uuid = socket.assigns.contact.uuid token = socket.assigns[:refresh_token] if socket.assigns[:loaded_uuid] == uuid and socket.assigns[:loaded_token] == token do socket else socket |> load_interactions() |> assign(:loaded_uuid, uuid) |> assign(:loaded_token, token) end end defp load_interactions(socket) do interactions = Interactions.list_involving(socket.assigns.contact.uuid) interaction_files = if socket.assigns[:storage_enabled], do: Attachments.list_files_by_interaction(Enum.map(interactions, & &1.uuid)), else: %{} socket |> assign(:interactions, interactions) |> assign(:interaction_files, interaction_files) end defp storage_enabled? do Storage.enabled?() rescue _ -> false end @impl true # Core's SearchPicker JS hook owns the search box + dropdown entirely (instant, # client-side). It pushes the (client-debounced) query here; we run the DB # search and hand rows back to the hook via push_event. No server-side search # state is kept. def handle_event("search_party", %{"q" => q} = params, socket) when is_binary(q) do q = String.trim(q) {results, has_more} = search_parties( q, socket.assigns.staff_enabled, parse_limit(params["limit"]), # The subject contact is already implied — keep them out of the picker. [socket.assigns.contact.uuid] ) {:noreply, push_event(socket, "crm_party_results", %{q: q, results: results, has_more: has_more})} end def handle_event("stage_party", %{"kind" => kind, "uuid" => uuid, "label" => label}, socket) when is_binary(kind) and is_binary(uuid) and is_binary(label) do party = %{raw_name: label, kind: kind, contact_uuid: nil, staff_person_uuid: nil} party = case kind do "contact" -> %{party | contact_uuid: uuid} "staff" -> %{party | staff_person_uuid: uuid} _ -> party end {:noreply, socket |> maybe_append(party) |> push_event("crm_party_staged", %{})} end def handle_event("stage_text", %{"name" => name}, socket) when is_binary(name) do name = String.trim(name) party = %{raw_name: name, kind: "text", contact_uuid: nil, staff_person_uuid: nil} socket = if name == "", do: socket, else: maybe_append(socket, party) {:noreply, push_event(socket, "crm_party_staged", %{})} end def handle_event("add_me", _params, socket) do case me_party(socket.assigns[:current_user_uuid], socket.assigns[:current_user_name]) do nil -> {:noreply, socket} party -> {:noreply, maybe_append(socket, party)} end end def handle_event("remove_party", %{"idx" => idx}, socket) do case Integer.parse(to_string(idx)) do {i, _} -> {:noreply, assign(socket, :staged_parties, List.delete_at(socket.assigns.staged_parties, i))} :error -> {:noreply, socket} end end def handle_event("composer_change", %{"interaction" => p}, socket) when is_map(p) do {:noreply, socket |> assign(:c_type, p["interaction_type"] || socket.assigns.c_type) |> assign(:c_subject, p["subject"] || "") |> assign(:c_body, p["body"] || "") |> assign(:c_occurred_at, p["occurred_at"] || socket.assigns.c_occurred_at) |> assign(:save_error, nil)} end def handle_event("composer_change", _params, socket), do: {:noreply, socket} def handle_event("set_now", _params, socket) do {:noreply, assign(socket, :c_occurred_at, local_now_str(socket.assigns[:tz_offset] || 0))} end def handle_event("save_interaction", _params, socket) do # `c_occurred_at` is the user's LOCAL time (profile tz); store true UTC. occurred_at = local_to_utc(socket.assigns.c_occurred_at, socket.assigns[:tz_offset] || 0) attrs = %{ "contact_uuid" => socket.assigns.contact.uuid, "interaction_type" => socket.assigns.c_type, "subject" => socket.assigns.c_subject, "body" => socket.assigns.c_body, "owner_user_uuid" => socket.assigns[:current_user_uuid] } |> maybe_put_occurred_at(occurred_at) party_inputs = Enum.map(socket.assigns.staged_parties, fn p -> %{ raw_name: p.raw_name, contact_uuid: p[:contact_uuid], staff_person_uuid: p[:staff_person_uuid] } end) file_uuids = Enum.map(socket.assigns.staged_files, & &1.uuid) case Interactions.create_interaction(attrs, party_inputs, file_uuids) do {:ok, _interaction} -> # (The audit-log entry, file attach, + realtime broadcast are emitted by # the context.) Reset the composer ONLY on success — every failure path # below leaves the typed fields + staged parties + files untouched. {:noreply, socket |> assign(:staged_parties, []) |> assign(:staged_files, []) |> assign(:c_type, "note") |> assign(:c_subject, "") |> assign(:c_body, "") |> assign(:c_occurred_at, local_now_str(socket.assigns[:tz_offset] || 0)) |> assign(:save_error, nil) |> load_interactions()} {:error, changeset} -> {:noreply, assign(socket, :save_error, changeset_message(changeset))} end rescue e -> Logger.error( "[CRM] save_interaction crashed (contact_uuid=#{inspect(socket.assigns.contact.uuid)}): " <> Exception.format(:error, e, __STACKTRACE__) ) {:noreply, assign(socket, :save_error, default_save_error())} end def handle_event("delete_interaction", %{"uuid" => uuid}, socket) do # Only delete interactions actually shown in this contact's feed — a forged # event must not be able to delete an unrelated interaction by uuid. in_feed? = Enum.any?(socket.assigns.interactions, &(&1.uuid == uuid)) with true <- in_feed?, %Interaction{} = i <- Interactions.get_interaction(uuid) do Interactions.delete_interaction(i, actor_uuid: socket.assigns[:current_user_uuid]) {:noreply, load_interactions(socket)} else _ -> {:noreply, socket} end end # The dropzone form's phx-change (auto_upload does the actual work via the # progress callback) — just acknowledge it. def handle_event("validate_attachment", _params, socket), do: {:noreply, socket} def handle_event("cancel_attachment", %{"ref" => ref}, socket), do: {:noreply, cancel_upload(socket, :attachments, ref)} def handle_event("remove_staged_file", %{"uuid" => uuid}, socket) do {:noreply, assign(socket, :staged_files, Enum.reject(socket.assigns.staged_files, &(&1.uuid == uuid)))} end # Ignore any unexpected/forged event rather than crashing the LiveView. def handle_event(_event, _params, socket), do: {:noreply, socket} # ── Inline upload (drag-drop / click) ────────────────────────────── # # Uploads are allowed on THIS component (like core's MediaSelectorModal), so # the dropzone lives inline in the composer — no modal. Each finished entry is # stored to a bucket (orphan, no folder), then staged; on save the staged # uuids are adopted into the interaction's folder. defp maybe_allow_upload(socket) do cond do uploads_allowed?(socket) -> assign(socket, :can_attach, true) socket.assigns.storage_enabled and Storage.list_enabled_buckets() != [] -> socket |> allow_upload(:attachments, accept: @upload_accept, max_entries: 10, max_file_size: @max_upload_size, auto_upload: true, progress: &handle_progress/3 ) |> assign(:can_attach, true) true -> assign(socket, :can_attach, false) end rescue _ -> assign(socket, :can_attach, false) end defp uploads_allowed?(socket) do match?(%{attachments: _}, socket.assigns[:uploads] || %{}) end defp handle_progress(:attachments, entry, socket) do if entry.done? do case consume_uploaded_entry(socket, entry, &store_upload(socket, &1.path, entry)) do uuid when is_binary(uuid) -> {:noreply, stage_files(socket, [uuid])} _ -> {:noreply, socket} end else {:noreply, socket} end end # Persist a consumed upload to a bucket (no folder yet — adopted on save). defp store_upload(socket, path, entry) do ext = entry.client_name |> Path.extname() |> String.replace_leading(".", "") mime = entry.client_type || MIME.from_path(entry.client_name) case socket.assigns[:phoenix_kit_current_user] do %{uuid: user_uuid} -> hash = Auth.calculate_file_hash(path) case Storage.store_file_in_buckets( path, file_type(mime), user_uuid, hash, ext, entry.client_name ) do {:ok, file, :duplicate} -> {:ok, file.uuid} {:ok, file} -> {:ok, file.uuid} _ -> {:postpone, :error} end _ -> {:postpone, :error} end rescue _ -> {:postpone, :error} end defp file_type("image/" <> _), do: "image" defp file_type("video/" <> _), do: "video" defp file_type(_), do: "other" # Add newly-uploaded files to the composer's staged list (deduped). The files # are attached to the interaction's folder when it's saved. defp stage_files(socket, uuids) do current = socket.assigns[:staged_files] || [] seen = MapSet.new(current, & &1.uuid) added = uuids |> Enum.reject(&MapSet.member?(seen, &1)) |> Enum.map(&Attachments.get_file/1) |> Enum.reject(&is_nil/1) assign(socket, :staged_files, current ++ added) end # Best-effort, user-facing message from a failed changeset (interaction or a # rolled-back party); details are logged, the input is preserved either way. defp changeset_message(%Ecto.Changeset{} = changeset) do changeset |> Ecto.Changeset.traverse_errors(fn {msg, opts} -> Enum.reduce(opts, to_string(msg), fn {k, v}, acc -> String.replace(acc, "%{#{k}}", safe_str(v)) end) end) |> Enum.flat_map(fn {_field, msgs} -> msgs end) |> List.first() |> case do detail when is_binary(detail) -> gettext("Couldn't save: %{detail}", detail: detail) _ -> default_save_error() end rescue # Never let the error-message builder itself crash the save handler. _ -> default_save_error() end defp default_save_error do gettext("Couldn't save this interaction. Your input was kept — please try again.") end defp safe_str(v) when is_binary(v), do: v defp safe_str(v) when is_atom(v) or is_number(v), do: to_string(v) defp safe_str(v), do: inspect(v) defp append_party(socket, party) do assign(socket, :staged_parties, socket.assigns.staged_parties ++ [party]) end defp maybe_append(socket, party) do if already_staged?(socket.assigns.staged_parties, party), do: socket, else: append_party(socket, party) end # "Add me" → the current user's linked CRM contact if any, else free text. # Tagged `is_me` for the "(you)" badge suffix (display-only; dropped on save). defp me_party(uuid, name) when is_binary(uuid) do base = case Contacts.get_by_user_uuid(uuid) do %Contact{} = c -> %{ raw_name: Contact.display_name(c), kind: "contact", contact_uuid: c.uuid, staff_person_uuid: nil } _ -> text_party(name) end mark_me(base) end defp me_party(_uuid, name), do: mark_me(text_party(name)) defp text_party(name) when is_binary(name) and name != "" do %{raw_name: name, kind: "text", contact_uuid: nil, staff_person_uuid: nil} end defp text_party(_), do: nil defp mark_me(nil), do: nil defp mark_me(party), do: Map.put(party, :is_me, true) defp me_staged?(parties), do: Enum.any?(parties, & &1[:is_me]) defp already_staged?(staged, %{contact_uuid: cu}) when is_binary(cu) do Enum.any?(staged, &(&1[:contact_uuid] == cu)) end defp already_staged?(staged, %{staff_person_uuid: su}) when is_binary(su) do Enum.any?(staged, &(&1[:staff_person_uuid] == su)) end defp already_staged?(staged, %{raw_name: name}) do Enum.any?(staged, &(&1[:raw_name] == name)) end # Fetch one extra per source so the hook knows whether to offer "Load more". defp search_parties(query, staff_enabled?, limit, exclude_uuids) do contacts = query |> Contacts.search_contacts(limit + 1, exclude_uuids) |> Enum.map(fn c -> %{ kind: "contact", uuid: c.uuid, label: Contact.display_name(c), sublabel: c.email || "", icon: "hero-user" } end) staff = if staff_enabled?, do: staff_results(query, limit + 1), else: [] has_more = length(contacts) > limit or length(staff) > limit {Enum.take(contacts, limit) ++ Enum.take(staff, limit), has_more} end defp staff_results(query, limit) do query |> StaffLink.search(limit) |> Enum.map(fn p -> %{ kind: "staff", uuid: p.uuid, label: p.name, sublabel: p[:job_title] || gettext("Staff"), icon: "hero-identification" } end) end @default_limit 8 @max_limit 60 defp parse_limit(n) when is_integer(n), do: n |> max(@default_limit) |> min(@max_limit) defp parse_limit(n) when is_binary(n) do case Integer.parse(n) do {i, _} -> parse_limit(i) _ -> @default_limit end end defp parse_limit(_), do: @default_limit defp maybe_put_occurred_at(attrs, nil), do: attrs defp maybe_put_occurred_at(attrs, %DateTime{} = dt), do: Map.put(attrs, "occurred_at", dt) # ── Timezone helpers (storage is always UTC; UI is in the user's profile tz) ── # "Now" in the user's timezone, formatted for a datetime-local input. defp local_now_str(offset) do DateTime.utc_now() |> DateTime.add(offset * 3600, :second) |> DateTime.truncate(:second) |> Calendar.strftime("%Y-%m-%dT%H:%M") end # A local datetime-local string (in the user's tz) → the true UTC instant. defp local_to_utc(value, offset) when is_binary(value) and value != "" do case NaiveDateTime.from_iso8601(value <> ":00") do {:ok, naive} -> naive |> DateTime.from_naive!("Etc/UTC") |> DateTime.add(-offset * 3600, :second) _ -> nil end end defp local_to_utc(_, _), do: nil # A stored UTC datetime → display string in the user's tz. defp format_local(nil, _offset), do: "—" defp format_local(%DateTime{} = utc, offset) do utc |> DateTime.add(offset * 3600, :second) |> Calendar.strftime("%Y-%m-%d %H:%M") end # Host-passed assigns (the contact this feed belongs to + the acting user's # context, threaded from the show LiveView). Declared so the call site is # checked and the contract is explicit. attr(:contact, :map, required: true) attr(:current_user_uuid, :string, default: nil) attr(:current_user_name, :string, default: nil) attr(:phoenix_kit_current_user, :map, default: nil) attr(:tz_offset, :integer, default: 0) @impl true def render(assigns) do ~H"""
<%!-- Composer --%>

{gettext("Log an interaction")}

<.form for={%{}} as={:interaction} phx-change="composer_change" phx-target={@myself} class="flex flex-col gap-3" > <.select id="crm-type" name="interaction[interaction_type]" value={@c_type} label={gettext("Type")} options={Enum.map(Interaction.types(), &{Interaction.type_label(&1), &1})} />
<.input type="datetime-local" id="crm-when" name="interaction[occurred_at]" value={@c_occurred_at} label={gettext("When")} phx-hook="CrmWhenWarnings" data-profile-offset={@tz_offset} data-warning-target="crm-when-warning" data-setnow-target="crm-set-now" />
<.input id="crm-subject" name="interaction[subject]" value={@c_subject} label={gettext("Subject")} placeholder={gettext("Optional")} /> <.textarea id="crm-body" name="interaction[body]" value={@c_body} label={gettext("What was discussed?")} /> <%!-- Attachments — real inline drag-drop / click dropzone (uploads like core's media picker); staged files attach on save. --%>
<.live_file_input upload={@uploads.attachments} class="hidden" />
<%!-- In-progress uploads --%>
{entry.client_name} {entry.progress}%
<%!-- Staged (uploaded, pending save) --%>
<.icon name={Attachments.file_icon(f)} class="w-3.5 h-3.5 shrink-0" /> {f.original_file_name || f.file_name}
<%!-- Involved parties — outside the <.form> so Enter in the search box never submits the composer (it only stages parties). --%>
<.icon name="hero-information-circle" class="w-3.5 h-3.5 text-base-content/40 group-hover:text-base-content cursor-help" />
{p.raw_name} {gettext("(you)")}
<%!-- Core SearchPicker: the dropdown is rendered + toggled entirely client-side (instant); the server (search_party) only returns rows via push_event. --%> <.search_picker id="crm-party-search" dropdown_id="crm-party-dropdown" search_event="search_party" results_event="crm_party_results" pick_event="stage_party" text_event="stage_text" staged_event="crm_party_staged" searching_label={gettext("Searching…")} add_prefix_label={gettext("Add")} add_suffix_label={gettext("as free text")} adding_label={gettext("Adding…")} more_label={gettext("Load more")} loading_more_label={gettext("Loading…")} placeholder={gettext("Type a name — searches contacts%{staff}…", staff: if(@staff_enabled, do: gettext(" and staff"), else: ""))} />
<.button type="button" phx-click="save_interaction" phx-target={@myself} class="btn-primary btn-sm" phx-disable-with={gettext("Saving…")} > {gettext("Save interaction")}
<%!-- Timeline --%> <.empty_state :if={@interactions == []} icon="hero-chat-bubble-left-right" title={gettext("No interactions logged yet.")} />
  1. {Interaction.type_label(i.interaction_type)} {format_local(i.occurred_at, @tz_offset)}
    {i.subject}
    {i.body}
    {gettext("Involved:")} <.party_badge :for={p <- i.parties} party={p} />
    <% files = Map.get(@interaction_files, i.uuid, []) %>
""" end end