diff --git a/config/config.exs b/config/config.exs index eff2a0a3..281c41b5 100644 --- a/config/config.exs +++ b/config/config.exs @@ -69,6 +69,9 @@ config :logger, :default_formatter, :mode, :phase, :reason, + :state, + :photo_id, + :journal_entry_id, :issue_codes, :details, :x, diff --git a/lib/gtfs_planner/gtfs.ex b/lib/gtfs_planner/gtfs.ex index 40fe611a..69818df1 100644 --- a/lib/gtfs_planner/gtfs.ex +++ b/lib/gtfs_planner/gtfs.ex @@ -27,6 +27,7 @@ defmodule GtfsPlanner.Gtfs do alias GtfsPlanner.Gtfs.FloorplanTransform alias GtfsPlanner.Gtfs.Frequency alias GtfsPlanner.Gtfs.Level + alias GtfsPlanner.Gtfs.JournalEntry alias GtfsPlanner.Gtfs.Location alias GtfsPlanner.Gtfs.Network alias GtfsPlanner.Gtfs.Pathway @@ -36,6 +37,8 @@ defmodule GtfsPlanner.Gtfs do alias GtfsPlanner.Gtfs.RoutePattern alias GtfsPlanner.Gtfs.Shape alias GtfsPlanner.Gtfs.StationEditingStatus + alias GtfsPlanner.Gtfs.StationJournal + alias GtfsPlanner.Gtfs.StationJournal.Scope alias GtfsPlanner.Gtfs.StationNaming alias GtfsPlanner.Gtfs.Stop alias GtfsPlanner.Gtfs.StopArea @@ -61,6 +64,34 @@ defmodule GtfsPlanner.Gtfs do location_type: 0 | 1 | 2 | 3 | 4 | String.t() | nil ] + @spec resolve_station_journal_scope(Ecto.UUID.t(), Ecto.UUID.t(), Ecto.UUID.t(), Ecto.UUID.t()) :: + {:ok, Scope.t()} | {:error, :not_found | :invalid_id} + def resolve_station_journal_scope(organization_id, gtfs_version_id, station_id, actor_id), + do: StationJournal.resolve_scope(organization_id, gtfs_version_id, station_id, actor_id) + + @spec sync_journal_entries(Scope.t(), [map()]) :: %{ + synced_count: non_neg_integer(), + errors: [map()] + } + def sync_journal_entries(%Scope{} = scope, entries), + do: StationJournal.sync_entries(scope, entries) + + @spec list_station_journal(Scope.t()) :: [JournalEntry.t()] + def list_station_journal(%Scope{} = scope), do: StationJournal.list_entries(scope) + + @spec create_journal_photo( + Scope.t(), + map(), + %{path: String.t(), filename: String.t(), content_type: String.t() | nil} + ) :: {:ok, GtfsPlanner.Gtfs.JournalPhoto.t()} | {:error, atom() | Ecto.Changeset.t()} + def create_journal_photo(%Scope{} = scope, attrs, upload), + do: StationJournal.create_photo(scope, attrs, upload) + + @spec refresh_pin_coordinates_for_stop_level(StopLevel.t(), pos_integer(), pos_integer()) :: + {:ok, non_neg_integer()} | {:error, term()} + def refresh_pin_coordinates_for_stop_level(%StopLevel{} = stop_level, image_w, image_h), + do: StationJournal.refresh_pin_coordinates_for_stop_level(stop_level, image_w, image_h) + @doc """ Returns the list of routes for an organization and GTFS version. @@ -594,7 +625,9 @@ defmodule GtfsPlanner.Gtfs do |> Repo.update(), {:ok, active_derived} <- derive_child_stop_coords(updated_stop_level, image_w, image_h), - {:ok, active_updated_stops} <- persist_derived_coords_in_tx(active_derived) do + {:ok, active_updated_stops} <- persist_derived_coords_in_tx(active_derived), + {:ok, _refreshed_pin_count} <- + refresh_pin_coordinates_for_stop_level(updated_stop_level, image_w, image_h) do Logger.debug(fn -> "[ALIGN_APPLY_DEBUG] active_stop_level_persisted " <> inspect(%{ diff --git a/lib/gtfs_planner/gtfs/journal_entry.ex b/lib/gtfs_planner/gtfs/journal_entry.ex new file mode 100644 index 00000000..a91c523e --- /dev/null +++ b/lib/gtfs_planner/gtfs/journal_entry.ex @@ -0,0 +1,148 @@ +defmodule GtfsPlanner.Gtfs.JournalEntry do + use Ecto.Schema + + import Ecto.Changeset + + alias GtfsPlanner.Gtfs.StationJournal.Scope + + @target_types ~w(station node pathway pin) + @primary_key {:id, :binary_id, autogenerate: false} + @foreign_key_type :binary_id + + @type t :: %__MODULE__{ + id: Ecto.UUID.t(), + organization_id: Ecto.UUID.t(), + gtfs_version_id: Ecto.UUID.t(), + station_id: Ecto.UUID.t(), + author_id: Ecto.UUID.t(), + target_type: String.t(), + target_id: Ecto.UUID.t() | nil, + stop_level_id: Ecto.UUID.t() | nil, + diagram_x: float() | nil, + diagram_y: float() | nil, + body: String.t() | nil, + captured_at: DateTime.t(), + closed_at: DateTime.t() | nil, + closed_by: Ecto.UUID.t() | nil, + lat: float() | nil, + lon: float() | nil, + inserted_at: DateTime.t(), + updated_at: DateTime.t() + } + + schema "journal_entries" do + belongs_to :organization, GtfsPlanner.Organizations.Organization + belongs_to :gtfs_version, GtfsPlanner.Versions.GtfsVersion + belongs_to :station, GtfsPlanner.Gtfs.Stop + + field :author_id, :binary_id + field :target_type, :string + field :target_id, :binary_id + field :stop_level_id, :binary_id + field :diagram_x, :float + field :diagram_y, :float + field :body, :string + field :captured_at, :utc_datetime_usec + field :closed_at, :utc_datetime_usec + field :closed_by, :binary_id + field :lat, :float + field :lon, :float + + has_many :photos, GtfsPlanner.Gtfs.JournalPhoto + + timestamps(type: :utc_datetime_usec) + end + + @spec create_changeset(t(), map(), Scope.t()) :: Ecto.Changeset.t() + def create_changeset(entry, client_attrs, %Scope{} = scope) do + entry + |> cast(client_attrs, [ + :id, + :target_type, + :target_id, + :stop_level_id, + :diagram_x, + :diagram_y, + :body, + :captured_at + ]) + |> put_change(:organization_id, scope.organization_id) + |> put_change(:gtfs_version_id, scope.gtfs_version_id) + |> put_change(:station_id, scope.station_id) + |> put_change(:author_id, scope.actor_id) + |> validate_entry_fields() + end + + @spec sync_changeset(t(), map()) :: Ecto.Changeset.t() + def sync_changeset(entry, client_attrs) do + entry + |> cast(client_attrs, [ + :target_type, + :target_id, + :stop_level_id, + :diagram_x, + :diagram_y, + :body + ]) + |> validate_entry_fields(required: false) + end + + @spec derived_coordinates_changeset(t(), %{lat: float() | nil, lon: float() | nil}) :: + Ecto.Changeset.t() + def derived_coordinates_changeset(entry, attrs) do + entry + |> cast(attrs, [:lat, :lon]) + |> validate_number(:lat, greater_than_or_equal_to: -90, less_than_or_equal_to: 90) + |> validate_number(:lon, greater_than_or_equal_to: -180, less_than_or_equal_to: 180) + end + + defp validate_entry_fields(changeset, options \\ []) do + required = + if Keyword.get(options, :required, true), do: [:id, :target_type, :captured_at], else: [] + + changeset + |> validate_required(required) + |> validate_inclusion(:target_type, @target_types) + |> validate_target_shape() + |> check_constraint(:target_type, name: :journal_entries_target_shape_ck) + |> check_constraint(:closed_at, name: :journal_entries_closure_pair_ck) + |> foreign_key_constraint(:organization_id) + |> foreign_key_constraint(:gtfs_version_id) + |> foreign_key_constraint(:station_id) + end + + defp validate_target_shape(changeset) do + target_type = get_field(changeset, :target_type) + target_id = get_field(changeset, :target_id) + stop_level_id = get_field(changeset, :stop_level_id) + diagram_x = get_field(changeset, :diagram_x) + diagram_y = get_field(changeset, :diagram_y) + + valid? = + case target_type do + "station" -> + is_nil(target_id) and is_nil(stop_level_id) and is_nil(diagram_x) and is_nil(diagram_y) + + type when type in ["node", "pathway"] -> + not is_nil(target_id) and is_nil(stop_level_id) and is_nil(diagram_x) and + is_nil(diagram_y) + + "pin" -> + is_nil(target_id) and not is_nil(stop_level_id) and finite_non_negative?(diagram_x) and + finite_non_negative?(diagram_y) + + _ -> + true + end + + if valid?, + do: changeset, + else: add_error(changeset, :target_type, "has an invalid target shape") + end + + defp finite_non_negative?(value) when is_integer(value), do: value >= 0 + + defp finite_non_negative?(value) when is_float(value), do: value >= 0 + + defp finite_non_negative?(_), do: false +end diff --git a/lib/gtfs_planner/gtfs/journal_photo.ex b/lib/gtfs_planner/gtfs/journal_photo.ex new file mode 100644 index 00000000..546d82b2 --- /dev/null +++ b/lib/gtfs_planner/gtfs/journal_photo.ex @@ -0,0 +1,74 @@ +defmodule GtfsPlanner.Gtfs.JournalPhoto do + use Ecto.Schema + + import Ecto.Changeset + + @content_types ~w(image/jpeg image/png) + @primary_key {:id, :binary_id, autogenerate: false} + @foreign_key_type :binary_id + + @type t :: %__MODULE__{ + id: Ecto.UUID.t(), + journal_entry_id: Ecto.UUID.t(), + filename: String.t(), + content_type: String.t(), + byte_size: pos_integer(), + sha256: binary(), + width: pos_integer() | nil, + height: pos_integer() | nil, + captured_at: DateTime.t(), + inserted_at: DateTime.t(), + updated_at: DateTime.t() + } + + schema "journal_photos" do + belongs_to :journal_entry, GtfsPlanner.Gtfs.JournalEntry + + field :filename, :string + field :content_type, :string + field :byte_size, :integer + field :sha256, :binary + field :width, :integer + field :height, :integer + field :captured_at, :utc_datetime_usec + + timestamps(type: :utc_datetime_usec) + end + + @spec create_changeset(t(), map()) :: Ecto.Changeset.t() + def create_changeset(photo, trusted_attrs) do + photo + |> cast(trusted_attrs, [ + :id, + :journal_entry_id, + :filename, + :content_type, + :byte_size, + :sha256, + :width, + :height, + :captured_at + ]) + |> validate_required([ + :id, + :journal_entry_id, + :filename, + :content_type, + :byte_size, + :sha256, + :captured_at + ]) + |> validate_inclusion(:content_type, @content_types) + |> validate_number(:byte_size, greater_than: 0) + |> validate_number(:width, greater_than: 0) + |> validate_number(:height, greater_than: 0) + |> validate_change(:sha256, fn :sha256, digest -> + if is_binary(digest) and byte_size(digest) == 32, do: [], else: [sha256: "must be 32 bytes"] + end) + |> check_constraint(:content_type, name: :journal_photos_content_type_ck) + |> check_constraint(:byte_size, name: :journal_photos_byte_size_positive_ck) + |> check_constraint(:width, name: :journal_photos_dimensions_positive_ck) + |> check_constraint(:sha256, name: :journal_photos_sha256_length_ck) + |> foreign_key_constraint(:journal_entry_id) + end +end diff --git a/lib/gtfs_planner/gtfs/station_journal.ex b/lib/gtfs_planner/gtfs/station_journal.ex new file mode 100644 index 00000000..c1012901 --- /dev/null +++ b/lib/gtfs_planner/gtfs/station_journal.ex @@ -0,0 +1,505 @@ +defmodule GtfsPlanner.Gtfs.StationJournal do + @moduledoc false + + import Ecto.Query + + alias GtfsPlanner.Gtfs + + alias GtfsPlanner.Gtfs.{ + Coordinates, + FloorplanTransform, + JournalEntry, + JournalPhoto, + Stop, + StopLevel + } + + alias GtfsPlanner.Gtfs.StationJournal.{PhotoStorage, Scope} + alias GtfsPlanner.Repo + + require Logger + + @type sync_error :: %{ + id: term(), + code: :invalid_id | :invalid_target | :id_conflict | :validation_error + } + + @spec resolve_scope(Ecto.UUID.t(), Ecto.UUID.t(), Ecto.UUID.t(), Ecto.UUID.t()) :: + {:ok, Scope.t()} | {:error, :not_found | :invalid_id} + def resolve_scope(organization_id, gtfs_version_id, station_id, actor_id) do + with {:ok, organization_id} <- cast_uuid(organization_id), + {:ok, gtfs_version_id} <- cast_uuid(gtfs_version_id), + {:ok, station_id} <- cast_uuid(station_id), + {:ok, actor_id} <- cast_uuid(actor_id) do + case Repo.one( + from(stop in Stop, + where: + stop.id == ^station_id and stop.organization_id == ^organization_id and + stop.gtfs_version_id == ^gtfs_version_id and stop.location_type == 1 and + is_nil(stop.parent_station) + ) + ) do + nil -> + {:error, :not_found} + + station -> + {:ok, + %Scope{ + organization_id: organization_id, + gtfs_version_id: gtfs_version_id, + station_id: station.id, + station_stop_id: station.stop_id, + actor_id: actor_id + }} + end + else + :error -> + {:error, :invalid_id} + end + end + + @spec sync_entries(Scope.t(), [map()]) :: %{ + synced_count: non_neg_integer(), + errors: [sync_error()] + } + def sync_entries(%Scope{} = scope, entries) when is_list(entries) do + targets = target_index(scope) + + result = + Enum.reduce(entries, %{synced_count: 0, errors: []}, fn attrs, result -> + case sync_entry(scope, targets, attrs) do + :ok -> + %{result | synced_count: result.synced_count + 1} + + {:error, code} -> + %{result | errors: [%{id: error_id(attrs), code: code} | result.errors]} + end + end) + + %{result | errors: Enum.reverse(result.errors)} + end + + @spec list_entries(Scope.t()) :: [JournalEntry.t()] + def list_entries(%Scope{} = scope) do + photos = + from(photo in JournalPhoto, + order_by: [asc: photo.captured_at, asc: photo.inserted_at, asc: photo.id] + ) + + from(entry in JournalEntry, + where: + entry.organization_id == ^scope.organization_id and + entry.gtfs_version_id == ^scope.gtfs_version_id and + entry.station_id == ^scope.station_id, + order_by: [asc: entry.captured_at, asc: entry.inserted_at, asc: entry.id], + preload: [photos: ^photos] + ) + |> Repo.all() + end + + @spec refresh_pin_coordinates_for_stop_level(StopLevel.t(), pos_integer(), pos_integer()) :: + {:ok, non_neg_integer()} | {:error, term()} + def refresh_pin_coordinates_for_stop_level(%StopLevel{} = stop_level, image_w, image_h) + when is_integer(image_w) and image_w > 0 and is_integer(image_h) and image_h > 0 do + with {:ok, alignment} <- StopLevel.alignment_transform(stop_level) do + from(entry in JournalEntry, + where: + entry.organization_id == ^stop_level.organization_id and + entry.gtfs_version_id == ^stop_level.gtfs_version_id and + entry.station_id == ^stop_level.stop_id and entry.target_type == "pin" and + entry.stop_level_id == ^stop_level.id, + lock: "FOR UPDATE" + ) + |> Repo.all() + |> Enum.reduce_while({:ok, 0}, fn entry, {:ok, count} -> + case Coordinates.normalize_point(%{x: entry.diagram_x, y: entry.diagram_y}) do + nil -> + {:cont, {:ok, count}} + + point -> + with {:ok, {lat, lon}} <- + FloorplanTransform.svg_to_lat_lon(alignment, image_w, image_h, point), + {:ok, _entry} <- + entry + |> JournalEntry.derived_coordinates_changeset(%{lat: lat, lon: lon}) + |> Repo.update() do + {:cont, {:ok, count + 1}} + else + {:error, reason} -> {:halt, {:error, reason}} + end + end + end) + end + end + + def refresh_pin_coordinates_for_stop_level(_, _, _), do: {:error, :invalid_input} + + @spec create_photo(Scope.t(), map(), %{ + path: String.t(), + filename: String.t(), + content_type: String.t() | nil + }) :: + {:ok, JournalPhoto.t()} | {:error, atom() | Ecto.Changeset.t()} + def create_photo(%Scope{} = scope, attrs, upload) when is_map(attrs) and is_map(upload) do + with {:ok, photo_id} <- cast_uuid(attr(attrs, :id)), + {:ok, entry_id} <- cast_uuid(attr(attrs, :journal_entry_id)), + {:ok, staged} <- PhotoStorage.stage(scope, photo_id, upload) do + create_staged_photo(scope, photo_id, entry_id, attrs, staged) + else + :error -> {:error, :invalid_id} + {:error, reason} -> {:error, reason} + end + end + + def create_photo(_scope, _attrs, _upload), do: {:error, :validation_error} + + defp create_staged_photo(scope, photo_id, entry_id, attrs, staged) do + with :ok <- validate_captured_at(attr(attrs, :captured_at)) do + result = Repo.transaction(fn -> persist_photo(scope, photo_id, entry_id, attrs, staged) end) + + case result do + {:ok, {:ok, photo}} -> + {:ok, photo} + + {:ok, {:error, reason}} -> + PhotoStorage.discard(staged) + {:error, reason} + + {:error, %Ecto.Changeset{} = changeset} -> + PhotoStorage.discard(staged) + {:error, changeset} + + {:error, reason} -> + PhotoStorage.discard(staged) + {:error, reason} + end + else + {:error, reason} -> + PhotoStorage.discard(staged) + {:error, reason} + end + end + + defp persist_photo(scope, photo_id, entry_id, attrs, staged) do + with %JournalEntry{} = entry <- locked_scoped_entry(scope, entry_id) do + case Repo.one(from(photo in JournalPhoto, where: photo.id == ^photo_id, lock: "FOR UPDATE")) do + nil -> + with :ok <- validate_create_photo_metadata(attrs, staged) do + create_or_adopt_photo(entry, photo_id, attrs, staged) + end + + photo -> + retry_photo(entry, photo, staged) + end + else + nil -> {:error, :not_found} + end + end + + defp locked_scoped_entry(scope, entry_id) do + Repo.one( + from(entry in JournalEntry, + where: + entry.id == ^entry_id and entry.organization_id == ^scope.organization_id and + entry.gtfs_version_id == ^scope.gtfs_version_id and + entry.station_id == ^scope.station_id, + lock: "FOR UPDATE" + ) + ) + end + + defp create_or_adopt_photo(entry, photo_id, attrs, staged) do + if PhotoStorage.canonical_conflict?(staged) do + log_storage(:orphan_conflict, photo_id, entry.id) + {:error, :id_conflict} + else + photo_attrs = trusted_photo_attrs(photo_id, entry.id, attrs, staged) + + case JournalPhoto.create_changeset(%JournalPhoto{}, photo_attrs) + |> Repo.insert(on_conflict: :nothing, conflict_target: :id) do + {:ok, _photo} -> + case Repo.one( + from(photo in JournalPhoto, where: photo.id == ^photo_id, lock: "FOR UPDATE") + ) do + nil -> + Repo.rollback(:photo_not_found) + + photo -> + if photo.journal_entry_id == entry.id and photo.sha256 == staged.sha256 and + photo.content_type == staged.content_type do + if File.exists?(staged.final_path) do + PhotoStorage.discard(staged) + log_storage(:orphan_adopted, photo_id, entry.id) + else + finalize_or_rollback(photo_id, entry.id, staged) + end + + {:ok, photo} + else + log_storage(:id_conflict, photo_id, entry.id) + {:error, :id_conflict} + end + end + + {:error, changeset} -> + Repo.rollback(changeset) + end + end + end + + defp retry_photo(entry, photo, staged) do + if photo.journal_entry_id == entry.id and photo.sha256 == staged.sha256 and + photo.content_type == staged.content_type do + if PhotoStorage.final_matches?(staged) do + PhotoStorage.discard(staged) + else + log_storage(:file_repaired, photo.id, entry.id) + + case PhotoStorage.finalize(staged) do + :ok -> + :ok + + {:error, reason} -> + log_storage(:rename_failed, photo.id, entry.id) + Repo.rollback(reason) + end + end + + {:ok, photo} + else + log_storage(:id_conflict, photo.id, entry.id) + {:error, :id_conflict} + end + end + + defp finalize_or_rollback(photo_id, entry_id, staged) do + case PhotoStorage.finalize(staged) do + :ok -> + :ok + + {:error, reason} -> + log_storage(:rename_failed, photo_id, entry_id) + Repo.rollback(reason) + end + end + + defp trusted_photo_attrs(photo_id, entry_id, attrs, staged) do + %{ + id: photo_id, + journal_entry_id: entry_id, + filename: staged.filename, + content_type: staged.content_type, + byte_size: staged.byte_size, + sha256: staged.sha256, + width: attr(attrs, :width), + height: attr(attrs, :height), + captured_at: attr(attrs, :captured_at) + } + end + + defp validate_content_type_hint(nil, _detected), do: :ok + defp validate_content_type_hint(hint, detected) when hint == detected, do: :ok + defp validate_content_type_hint(_hint, _detected), do: {:error, :validation_error} + defp validate_captured_at(nil), do: {:error, :validation_error} + + defp validate_captured_at(value) do + case Ecto.Type.cast(:utc_datetime_usec, value) do + {:ok, _captured_at} -> :ok + :error -> {:error, :validation_error} + end + end + + defp validate_create_photo_metadata(attrs, staged) do + with :ok <- validate_content_type_hint(attr(attrs, :content_type), staged.content_type), + :ok <- validate_dimension(attr(attrs, :width)), + :ok <- validate_dimension(attr(attrs, :height)) do + :ok + end + end + + defp validate_dimension(nil), do: :ok + defp validate_dimension(value) when is_integer(value) and value > 0, do: :ok + defp validate_dimension(_value), do: {:error, :validation_error} + + defp log_storage(state, photo_id, entry_id) do + Logger.warning( + "station_journal_photo_storage", + state: state, + photo_id: photo_id, + journal_entry_id: entry_id + ) + end + + defp target_index(scope) do + %{ + node_ids: + scope.organization_id + |> Gtfs.list_child_stops_for_parent(scope.gtfs_version_id, scope.station_id) + |> Enum.map(& &1.id) + |> MapSet.new(), + pathway_ids: + scope.organization_id + |> Gtfs.list_pathways_for_station(scope.gtfs_version_id, scope.station_id) + |> Enum.map(& &1.id) + |> MapSet.new(), + stop_level_ids: + scope.organization_id + |> Gtfs.list_stop_levels_for_station(scope.gtfs_version_id, scope.station_id) + |> Enum.map(& &1.id) + |> MapSet.new() + } + end + + defp sync_entry(scope, targets, attrs) when is_map(attrs) do + case cast_uuid(attr(attrs, :id)) do + {:ok, id} -> + case Repo.get(JournalEntry, id) do + nil -> sync_new_entry(scope, targets, id, attrs) + entry -> sync_existing_entry(scope, targets, id, entry, attrs) + end + + :error -> + {:error, :invalid_id} + end + end + + defp sync_entry(_scope, _targets, _attrs), do: {:error, :validation_error} + + defp sync_new_entry(scope, targets, id, attrs) do + with :ok <- validate_target(targets, attrs) do + changeset = JournalEntry.create_changeset(%JournalEntry{}, attrs, scope) + + if changeset.valid? do + sync_entry_transaction(fn -> persist_entry(scope, id, attrs, changeset) end) + else + {:error, :validation_error} + end + end + end + + defp sync_existing_entry(scope, targets, id, entry, attrs) do + if owned_by_scope?(entry, scope) do + sync_entry_transaction(fn -> persist_existing_entry(scope, targets, id, attrs) end) + else + {:error, :id_conflict} + end + end + + defp sync_entry_transaction(fun) do + case Repo.transaction(fun) do + {:ok, :ok} -> :ok + {:ok, {:error, code}} -> {:error, code} + {:error, _reason} -> {:error, :validation_error} + end + end + + defp persist_entry(scope, id, attrs, changeset) do + case Repo.insert(changeset, on_conflict: :nothing, conflict_target: :id) do + {:ok, _entry} -> + case Repo.one(from(entry in JournalEntry, where: entry.id == ^id, lock: "FOR UPDATE")) do + nil -> Repo.rollback(:entry_not_found) + entry -> update_or_conflict(scope, entry, attrs) + end + + {:error, changeset} -> + if changeset.errors == [], + do: Repo.rollback(:insert_failed), + else: {:error, :validation_error} + end + end + + defp persist_existing_entry(scope, targets, id, attrs) do + case Repo.one(from(entry in JournalEntry, where: entry.id == ^id, lock: "FOR UPDATE")) do + nil -> + {:error, :validation_error} + + entry -> + if owned_by_scope?(entry, scope) do + changeset = JournalEntry.sync_changeset(entry, attrs) + + with true <- changeset.valid?, + :ok <- validate_target(targets, target_attrs(changeset)) do + case Repo.update(changeset) do + {:ok, _entry} -> :ok + {:error, _changeset} -> {:error, :validation_error} + end + else + false -> {:error, :validation_error} + {:error, :invalid_target} -> {:error, :invalid_target} + end + else + {:error, :id_conflict} + end + end + end + + defp update_or_conflict(scope, entry, attrs) do + if owned_by_scope?(entry, scope) do + case Repo.update(JournalEntry.sync_changeset(entry, attrs)) do + {:ok, _entry} -> :ok + {:error, _changeset} -> {:error, :validation_error} + end + else + {:error, :id_conflict} + end + end + + defp validate_target(targets, attrs) do + case attr(attrs, :target_type) do + "station" -> valid_station_target?(attrs) + "node" -> valid_reference_target?(targets.node_ids, attrs) + "pathway" -> valid_reference_target?(targets.pathway_ids, attrs) + "pin" -> valid_pin_target?(targets.stop_level_ids, attrs) + _ -> false + end + |> case do + true -> :ok + false -> {:error, :invalid_target} + end + end + + defp target_attrs(changeset) do + Map.new([:target_type, :target_id, :stop_level_id, :diagram_x, :diagram_y], fn field -> + {field, Ecto.Changeset.get_field(changeset, field)} + end) + end + + defp valid_station_target?(attrs) do + blank?(attr(attrs, :target_id)) and blank?(attr(attrs, :stop_level_id)) and + blank?(attr(attrs, :diagram_x)) and blank?(attr(attrs, :diagram_y)) + end + + defp valid_reference_target?(ids, attrs) do + member_target?(ids, attr(attrs, :target_id)) and blank?(attr(attrs, :stop_level_id)) and + blank?(attr(attrs, :diagram_x)) and blank?(attr(attrs, :diagram_y)) + end + + defp valid_pin_target?(ids, attrs) do + blank?(attr(attrs, :target_id)) and member_target?(ids, attr(attrs, :stop_level_id)) and + finite_non_negative?(attr(attrs, :diagram_x)) and + finite_non_negative?(attr(attrs, :diagram_y)) + end + + defp member_target?(set, value) do + case cast_uuid(value) do + {:ok, uuid} -> MapSet.member?(set, uuid) + :error -> false + end + end + + defp finite_non_negative?(value) when is_integer(value), do: value >= 0 + defp finite_non_negative?(value) when is_float(value), do: value >= 0 + defp finite_non_negative?(_value), do: false + defp blank?(value), do: is_nil(value) + defp attr(attrs, key), do: Map.get(attrs, key) || Map.get(attrs, Atom.to_string(key)) + defp error_id(attrs) when is_map(attrs), do: attr(attrs, :id) + defp error_id(_attrs), do: nil + defp cast_uuid(value) when is_binary(value), do: Ecto.UUID.cast(value) + defp cast_uuid(_value), do: :error + + defp owned_by_scope?(entry, scope) do + entry.organization_id == scope.organization_id and + entry.gtfs_version_id == scope.gtfs_version_id and + entry.station_id == scope.station_id + end +end diff --git a/lib/gtfs_planner/gtfs/station_journal/photo_storage.ex b/lib/gtfs_planner/gtfs/station_journal/photo_storage.ex new file mode 100644 index 00000000..8f126ae1 --- /dev/null +++ b/lib/gtfs_planner/gtfs/station_journal/photo_storage.ex @@ -0,0 +1,297 @@ +defmodule GtfsPlanner.Gtfs.StationJournal.PhotoStorage do + @moduledoc false + + import Kernel, except: [inspect: 1] + + alias GtfsPlanner.Gtfs.Extensions.PathSafety + alias GtfsPlanner.Gtfs.StationJournal.Scope + + @max_bytes 25 * 1024 * 1024 + @chunk_size 64 * 1024 + @png_signature <<137, 80, 78, 71, 13, 10, 26, 10>> + @png_end <<0, 0, 0, 0, "IEND", 174, 66, 96, 130>> + + @type inspected :: %{ + path: String.t(), + final_path: String.t(), + filename: String.t(), + content_type: String.t(), + byte_size: pos_integer(), + sha256: binary(), + lock: term() + } + + @spec stage(Scope.t(), Ecto.UUID.t(), %{path: String.t()}) :: + {:ok, inspected()} | {:error, atom()} + def stage(%Scope{} = scope, photo_id, %{path: source_path}) when is_binary(source_path) do + with {:ok, base_path, id} <- final_path(scope, photo_id), + :ok <- ensure_directory(Path.dirname(base_path)) do + lock = acquire_lock(scope, id) + cleanup_stale_path(base_path) + + case stage_file(source_path, base_path, photo_id, id) do + {:ok, staged} -> + {:ok, Map.put(staged, :lock, lock)} + + {:error, reason} -> + cleanup_stale_path(base_path) + release_lock(lock) + {:error, reason} + end + end + end + + def stage(_scope, _photo_id, _upload), do: {:error, :invalid_upload} + + @spec discard(inspected()) :: :ok + def discard(%{path: path} = staged) do + try do + _ = File.rm(path) + :ok + after + release_lock(staged.lock) + end + end + + @spec finalize(inspected()) :: :ok | {:error, term()} + def finalize(%{path: path, final_path: final_path} = staged) do + try do + case File.rename(path, final_path) do + :ok -> :ok + {:error, _reason} -> {:error, :rename_failed} + end + after + release_lock(staged.lock) + end + end + + @spec final_matches?(inspected()) :: boolean() + def final_matches?(%{final_path: path} = staged) do + case inspect(path) do + {:ok, current} -> + current.sha256 == staged.sha256 and current.content_type == staged.content_type + + {:error, _reason} -> + false + end + end + + @spec canonical_conflict?(inspected()) :: boolean() + def canonical_conflict?(%{final_path: final_path} = staged) do + base_path = Path.rootname(final_path) + + Enum.any?([".jpg", ".png"], fn extension -> + candidate = base_path <> extension + File.exists?(candidate) and (candidate != final_path or not final_matches?(staged)) + end) + end + + @spec inspect(String.t()) :: + {:ok, %{content_type: String.t(), byte_size: pos_integer(), sha256: binary()}} + | {:error, atom()} + def inspect(path) when is_binary(path), do: inspect_file(path) + + @spec public_path(Scope.t(), %{filename: String.t()}) :: String.t() + def public_path(%Scope{} = scope, %{filename: filename}) do + station_dir = PathSafety.stop_storage_dir(scope.station_stop_id) + "/uploads/field-captures/#{scope.organization_id}/#{station_dir}/#{filename}" + end + + defp final_path(%Scope{} = scope, photo_id) do + with true <- PathSafety.safe_path_component?(scope.organization_id), + station_dir when is_binary(station_dir) <- + PathSafety.stop_storage_dir(scope.station_stop_id), + true <- PathSafety.safe_path_component?(station_dir), + {:ok, id} <- Ecto.UUID.cast(photo_id) do + root = Application.fetch_env!(:gtfs_planner, :uploads_path) + field_root = Path.join(root, "field-captures") + path = Path.join([field_root, scope.organization_id, station_dir, id]) + + if PathSafety.ensure_within_root(field_root, path) == :ok, + do: {:ok, path, id}, + else: {:error, :unsafe_path} + else + _ -> {:error, :unsafe_path} + end + end + + defp temporary_path(final_path, photo_id) do + temp = final_path <> ".#{photo_id}.#{System.unique_integer([:positive])}.tmp" + + if PathSafety.ensure_within_root(Path.dirname(final_path), temp) == :ok, + do: {:ok, temp}, + else: {:error, :unsafe_path} + end + + defp ensure_directory(path) do + case File.mkdir_p(path) do + :ok -> :ok + {:error, _reason} -> {:error, :storage_error} + end + end + + defp stage_file(source_path, base_path, photo_id, id) do + with {:ok, staged_path} <- temporary_path(base_path, photo_id), + {:ok, result} <- copy_and_inspect(source_path, staged_path) do + filename = id <> result.extension + + {:ok, + Map.merge(result, %{ + path: staged_path, + final_path: base_path <> result.extension, + filename: filename + })} + end + end + + defp acquire_lock(scope, photo_id) do + lock = {{__MODULE__, scope.organization_id, scope.station_id, photo_id}, self()} + true = :global.set_lock(lock) + lock + end + + defp release_lock(lock) do + :global.del_lock(lock) + :ok + end + + defp cleanup_stale_path(base_path) do + id = Path.basename(base_path) + pattern = Path.join(Path.dirname(base_path), "#{id}.*.tmp") + + Enum.each(Path.wildcard(pattern), fn path -> + _ = File.rm(path) + end) + + :ok + end + + defp copy_and_inspect(source, staged) do + case File.open(source, [:read, :binary]) do + {:ok, input} -> + case File.open(staged, [:write, :binary]) do + {:ok, output} -> + result = copy_chunks(input, output, :crypto.hash_init(:sha256), 0, <<>>, <<>>) + File.close(output) + File.close(input) + + case result do + {:ok, inspected} -> + case detected_type(inspected.prefix, inspected.tail) do + {:ok, content_type, extension} -> + {:ok, + Map.put(inspected, :content_type, content_type) + |> Map.put(:extension, extension)} + + :error -> + File.rm(staged) + {:error, :invalid_image} + end + + {:error, reason} -> + File.rm(staged) + {:error, reason} + end + + {:error, _reason} -> + File.close(input) + {:error, :storage_error} + end + + {:error, _reason} -> + {:error, :invalid_upload} + end + end + + defp copy_chunks(input, output, hash, size, prefix, tail) do + case IO.binread(input, @chunk_size) do + :eof when size == 0 -> + {:error, :empty_file} + + :eof -> + {:ok, %{byte_size: size, sha256: :crypto.hash_final(hash), prefix: prefix, tail: tail}} + + {:error, _reason} -> + {:error, :invalid_upload} + + chunk when is_binary(chunk) -> + next_size = size + byte_size(chunk) + + if next_size > @max_bytes do + {:error, :payload_too_large} + else + :ok = IO.binwrite(output, chunk) + + copy_chunks( + input, + output, + :crypto.hash_update(hash, chunk), + next_size, + take_prefix(prefix, chunk), + take_tail(tail, chunk) + ) + end + end + end + + defp inspect_file(path) do + case File.open(path, [:read, :binary]) do + {:ok, input} -> + result = inspect_chunks(input, :crypto.hash_init(:sha256), 0, <<>>, <<>>) + File.close(input) + result + + {:error, _reason} -> + {:error, :missing_file} + end + end + + defp inspect_chunks(input, hash, size, prefix, tail) do + case IO.binread(input, @chunk_size) do + :eof when size == 0 -> + {:error, :empty_file} + + :eof -> + case detected_type(prefix, tail) do + {:ok, content_type, _extension} -> + {:ok, + %{content_type: content_type, byte_size: size, sha256: :crypto.hash_final(hash)}} + + :error -> + {:error, :invalid_image} + end + + chunk when is_binary(chunk) -> + inspect_chunks( + input, + :crypto.hash_update(hash, chunk), + size + byte_size(chunk), + take_prefix(prefix, chunk), + take_tail(tail, chunk) + ) + + {:error, _reason} -> + {:error, :invalid_upload} + end + end + + defp detected_type(<<0xFF, 0xD8, _::binary>>, tail) + when byte_size(tail) >= 2 and binary_part(tail, byte_size(tail) - 2, 2) == <<0xFF, 0xD9>>, + do: {:ok, "image/jpeg", ".jpg"} + + defp detected_type(@png_signature <> _rest, tail) + when byte_size(tail) >= byte_size(@png_end) and + binary_part(tail, byte_size(tail) - byte_size(@png_end), byte_size(@png_end)) == + @png_end, + do: {:ok, "image/png", ".png"} + + defp detected_type(_prefix, _tail), do: :error + + defp take_prefix(prefix, chunk), + do: binary_part(prefix <> chunk, 0, min(8, byte_size(prefix <> chunk))) + + defp take_tail(tail, chunk) do + combined = tail <> chunk + binary_part(combined, max(byte_size(combined) - 12, 0), min(12, byte_size(combined))) + end +end diff --git a/lib/gtfs_planner/gtfs/station_journal/scope.ex b/lib/gtfs_planner/gtfs/station_journal/scope.ex new file mode 100644 index 00000000..f86aa6e7 --- /dev/null +++ b/lib/gtfs_planner/gtfs/station_journal/scope.ex @@ -0,0 +1,19 @@ +defmodule GtfsPlanner.Gtfs.StationJournal.Scope do + @moduledoc """ + Trusted scope for all station-journal operations. + + Scope values are resolved from the authenticated request and station lookup; they + are never derived from client journal attributes. + """ + + @enforce_keys [:organization_id, :gtfs_version_id, :station_id, :station_stop_id, :actor_id] + defstruct @enforce_keys + + @type t :: %__MODULE__{ + organization_id: Ecto.UUID.t(), + gtfs_version_id: Ecto.UUID.t(), + station_id: Ecto.UUID.t(), + station_stop_id: String.t(), + actor_id: Ecto.UUID.t() + } +end diff --git a/lib/gtfs_planner_web/api/v1/auth_controller.ex b/lib/gtfs_planner_web/api/v1/auth_controller.ex index 35b74784..17bca20d 100644 --- a/lib/gtfs_planner_web/api/v1/auth_controller.ex +++ b/lib/gtfs_planner_web/api/v1/auth_controller.ex @@ -42,7 +42,7 @@ defmodule GtfsPlannerWeb.Api.V1.AuthController do |> DateTime.add(@token_ttl_days * 24 * 60 * 60, :second) |> DateTime.truncate(:second) - {:ok, token, user, membership.organization_id, expires_at} + {:ok, token, user, membership, expires_at} else nil -> {:error, :invalid_credentials} {:error, _} = error -> error @@ -58,12 +58,13 @@ defmodule GtfsPlannerWeb.Api.V1.AuthController do end end - defp send_login_response(conn, {:ok, token, user, organization_id, expires_at}) do + defp send_login_response(conn, {:ok, token, user, membership, expires_at}) do json(conn, %{ data: %{ token: token, user: %{id: user.id, email: user.email}, - organization_id: organization_id, + organization_id: membership.organization_id, + roles: membership.roles, expires_at: DateTime.to_iso8601(expires_at) } }) diff --git a/lib/gtfs_planner_web/api/v1/journal_json.ex b/lib/gtfs_planner_web/api/v1/journal_json.ex new file mode 100644 index 00000000..f24520ef --- /dev/null +++ b/lib/gtfs_planner_web/api/v1/journal_json.ex @@ -0,0 +1,52 @@ +defmodule GtfsPlannerWeb.Api.V1.JournalJSON do + @moduledoc false + + alias GtfsPlanner.Gtfs.JournalEntry + alias GtfsPlanner.Gtfs.JournalPhoto + alias GtfsPlanner.Gtfs.StationJournal.{PhotoStorage, Scope} + alias GtfsPlannerWeb.Endpoint + + @spec entry(JournalEntry.t(), Scope.t()) :: map() + def entry(%JournalEntry{target_type: "pin"} = entry, %Scope{} = scope) do + entry + |> common_entry(scope) + |> Map.merge(%{ + stop_level_id: entry.stop_level_id, + diagram_coordinate: %{x: entry.diagram_x, y: entry.diagram_y}, + lat: entry.lat, + lon: entry.lon + }) + end + + def entry(%JournalEntry{} = entry, %Scope{} = scope) do + entry + |> common_entry(scope) + |> Map.put(:target_id, entry.target_id) + end + + @spec photo(JournalPhoto.t(), Scope.t()) :: map() + def photo(%JournalPhoto{} = photo, %Scope{} = scope) do + %{ + id: photo.id, + journal_entry_id: photo.journal_entry_id, + url: "#{Endpoint.url()}#{PhotoStorage.public_path(scope, photo)}", + content_type: photo.content_type, + width: photo.width, + height: photo.height, + captured_at: photo.captured_at + } + end + + defp common_entry(entry, scope) do + %{ + id: entry.id, + target_type: entry.target_type, + body: entry.body, + author_id: entry.author_id, + captured_at: entry.captured_at, + closed_at: entry.closed_at, + closed_by: entry.closed_by, + photos: Enum.map(entry.photos, &photo(&1, scope)) + } + end +end diff --git a/lib/gtfs_planner_web/api/v1/journal_photo_controller.ex b/lib/gtfs_planner_web/api/v1/journal_photo_controller.ex new file mode 100644 index 00000000..094b39ce --- /dev/null +++ b/lib/gtfs_planner_web/api/v1/journal_photo_controller.ex @@ -0,0 +1,77 @@ +defmodule GtfsPlannerWeb.Api.V1.JournalPhotoController do + use GtfsPlannerWeb, :controller + + alias GtfsPlanner.Gtfs + alias GtfsPlannerWeb.Api.V1.JournalJSON + + @doc "POST /api/v1/versions/:version_id/stations/:station_id/journal-photos" + def create(conn, %{"version_id" => version_id, "station_id" => station_id} = params) do + with {:ok, scope} <- resolve_scope(conn, version_id, station_id), + {:ok, metadata} <- metadata(params["metadata"]), + {:ok, upload} <- upload(params["file"]), + {:ok, photo} <- Gtfs.create_journal_photo(scope, metadata, upload) do + conn + |> put_status(:created) + |> json(%{data: %{photo: JournalJSON.photo(photo, scope)}}) + else + {:error, :bad_request} -> + bad_request(conn) + + {:error, :not_found} -> + not_found(conn) + + {:error, :id_conflict} -> + error(conn, 409, "id_conflict") + + {:error, :payload_too_large} -> + error(conn, 413, "payload_too_large") + + {:error, reason} when reason in [:unsafe_path, :storage_error, :rename_failed] -> + error(conn, 500, "storage_error") + + {:error, _reason} -> + error(conn, 422, "validation_error") + end + end + + def create(conn, _params), do: bad_request(conn) + + defp resolve_scope(conn, version_id, station_id) do + case Gtfs.resolve_station_journal_scope( + conn.assigns.current_organization_id, + version_id, + station_id, + conn.assigns.current_user_id + ) do + {:ok, scope} -> {:ok, scope} + {:error, :invalid_id} -> {:error, :bad_request} + {:error, :not_found} -> {:error, :not_found} + end + end + + defp metadata(value) when is_map(value), do: {:ok, value} + + defp metadata(value) when is_binary(value) do + case Jason.decode(value) do + {:ok, decoded} when is_map(decoded) -> {:ok, decoded} + _ -> {:error, :validation_error} + end + end + + defp metadata(_value), do: {:error, :validation_error} + + defp upload(%Plug.Upload{} = upload) do + {:ok, %{path: upload.path, filename: upload.filename, content_type: upload.content_type}} + end + + defp upload(_value), do: {:error, :validation_error} + + defp bad_request(conn), do: error(conn, 400, "bad_request") + defp not_found(conn), do: error(conn, 404, "not_found") + + defp error(conn, status, code) do + conn + |> put_status(status) + |> json(%{error: %{code: code}}) + end +end diff --git a/lib/gtfs_planner_web/api/v1/station_controller.ex b/lib/gtfs_planner_web/api/v1/station_controller.ex index 4df1c4e9..b9b4eacf 100644 --- a/lib/gtfs_planner_web/api/v1/station_controller.ex +++ b/lib/gtfs_planner_web/api/v1/station_controller.ex @@ -3,8 +3,10 @@ defmodule GtfsPlannerWeb.Api.V1.StationController do alias GtfsPlanner.Gtfs alias GtfsPlanner.Gtfs.Extensions.PathSafety + alias GtfsPlanner.Gtfs.StationJournal.Scope alias GtfsPlanner.Gtfs.StopLevel alias GtfsPlanner.Versions + alias GtfsPlannerWeb.Api.V1.JournalJSON alias GtfsPlannerWeb.Endpoint @default_page 1 @@ -76,9 +78,21 @@ defmodule GtfsPlannerWeb.Api.V1.StationController do true <- station.gtfs_version_id == version_id, true <- station.location_type == 1, true <- is_nil(station.parent_station) do + journal_scope = %Scope{ + organization_id: org_id, + gtfs_version_id: version_id, + station_id: station.id, + station_stop_id: station.stop_id, + actor_id: conn.assigns.current_user_id + } + child_stops = Gtfs.list_child_stops_for_parent(org_id, version_id, station.id) levels = Gtfs.list_levels_for_station(org_id, version_id, station.id) pathways = Gtfs.list_pathways_for_station(org_id, version_id, station.id) + journal_entries = Gtfs.list_station_journal(journal_scope) + + entries_by_target = + Enum.group_by(journal_entries, &entry_target/1, &JournalJSON.entry(&1, journal_scope)) {station_lat, station_lon} = serialize_coordinates(station.stop_lat, station.stop_lon) @@ -96,9 +110,22 @@ defmodule GtfsPlannerWeb.Api.V1.StationController do lat: station_lat, lon: station_lon }, - levels: Enum.map(levels, &serialize_level(&1, org_id, station.stop_id)), - stops: Enum.map(child_stops, &serialize_stop/1), - pathways: Enum.map(pathways, &serialize_pathway/1), + levels: + Enum.map( + levels, + &serialize_level(&1, org_id, station.stop_id, entries_by_target) + ), + stops: + Enum.map( + child_stops, + &serialize_stop(&1, Map.get(entries_by_target, {"node", &1.id}, [])) + ), + pathways: + Enum.map( + pathways, + &serialize_pathway(&1, Map.get(entries_by_target, {"pathway", &1.id}, [])) + ), + journal_entries: Map.get(entries_by_target, {"station", nil}, []), diagrams: [], downloaded_at: DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() } @@ -117,20 +144,33 @@ defmodule GtfsPlannerWeb.Api.V1.StationController do end end - defp serialize_level(%{level: level} = level_data, org_id, station_stop_id) do - serialize_level(level, Map.get(level_data, :stop_level), org_id, station_stop_id) + defp serialize_level(%{level: level} = level_data, org_id, station_stop_id, entries_by_target) do + serialize_level( + level, + Map.get(level_data, :stop_level), + org_id, + station_stop_id, + entries_by_target + ) end - defp serialize_level(%{id: _} = level, stop_level, org_id, station_stop_id) do + defp serialize_level(%{id: _} = level, stop_level, org_id, station_stop_id, entries_by_target) do + stop_level_id = stop_level_id(stop_level) + %{ id: level.id, level_id: level.level_id, level_index: level.level_index, level_name: level.level_name, - floorplan: serialize_floorplan(stop_level, org_id, station_stop_id) + stop_level_id: stop_level_id, + floorplan: serialize_floorplan(stop_level, org_id, station_stop_id), + journal_entries: Map.get(entries_by_target, {"pin", stop_level_id}, []) } end + defp stop_level_id(%StopLevel{id: id}), do: id + defp stop_level_id(_stop_level), do: nil + # The diagram image is the primary spatial artifact, so emit the floorplan # whenever a level has an image — independent of geographic alignment. The # alignment transform (center/scale/rotation) is optional enrichment: present @@ -178,7 +218,7 @@ defmodule GtfsPlannerWeb.Api.V1.StationController do end end - defp serialize_stop(stop) do + defp serialize_stop(stop, journal_entries) do {lat, lon} = serialize_coordinates(stop.stop_lat, stop.stop_lon) %{ @@ -192,7 +232,8 @@ defmodule GtfsPlannerWeb.Api.V1.StationController do platform_code: stop.platform_code, diagram_coordinate: stop.diagram_coordinate, lat: lat, - lon: lon + lon: lon, + journal_entries: journal_entries } end @@ -233,7 +274,7 @@ defmodule GtfsPlannerWeb.Api.V1.StationController do end end - defp serialize_pathway(pathway) do + defp serialize_pathway(pathway, journal_entries) do %{ id: pathway.id, pathway_id: pathway.pathway_id, @@ -249,7 +290,14 @@ defmodule GtfsPlannerWeb.Api.V1.StationController do signposted_as: pathway.signposted_as, reversed_signposted_as: pathway.reversed_signposted_as, field_notes: pathway.field_notes, - field_completed_at: pathway.field_completed_at + field_completed_at: pathway.field_completed_at, + journal_entries: journal_entries } end + + defp entry_target(%{target_type: "pin", stop_level_id: stop_level_id}), + do: {"pin", stop_level_id} + + defp entry_target(%{target_type: target_type, target_id: target_id}), + do: {target_type, target_id} end diff --git a/lib/gtfs_planner_web/api/v1/sync_controller.ex b/lib/gtfs_planner_web/api/v1/sync_controller.ex index cc605304..d67e2091 100644 --- a/lib/gtfs_planner_web/api/v1/sync_controller.ex +++ b/lib/gtfs_planner_web/api/v1/sync_controller.ex @@ -1,124 +1,193 @@ defmodule GtfsPlannerWeb.Api.V1.SyncController do use GtfsPlannerWeb, :controller - alias GtfsPlanner.Repo + alias GtfsPlanner.Gtfs alias GtfsPlanner.Gtfs.Pathway @editable_fields ~w(traversal_time stair_count min_width signposted_as reversed_signposted_as field_notes field_completed_at)a + @max_journal_entries 100 # from_stop_id/to_stop_id are accepted ONLY as a swap of the pathway's own - # endpoints (the field "Reverse direction" action) — the stored pair in - # either order. Any other value pair is rejected per-pathway with - # `invalid_endpoints` and no fields are applied: sync can reverse a pathway, - # never rewire one. Omitting the pair preserves the pre-existing behavior. - # See the companion app's specs/api/sync.md. + # endpoints (the field "Reverse direction" action). Sync can reverse a + # pathway, never rewire one. @doc "POST /api/v1/versions/:version_id/stations/:station_id/sync" - def create(conn, %{ - "version_id" => _version_id, - "station_id" => _station_id, - "pathways" => pathway_updates - }) do - org_id = conn.assigns[:current_organization_id] + def create(conn, params) do + with {:ok, pathway_updates} <- required_list(params, "pathways"), + {:ok, journal_entries} <- optional_list(params, "journal_entries"), + :ok <- journal_batch_within_limit(journal_entries), + {:ok, scope} <- resolve_scope(conn, params), + {:ok, allowed_pathway_ids} <- allowed_pathway_ids(scope) do + pathway_results = sync_pathways(pathway_updates, allowed_pathway_ids) + journal_results = sync_journal(scope, journal_entries) + + conn + |> json( + sync_response(pathway_results, journal_results, Map.has_key?(params, "journal_entries")) + ) + else + {:error, :invalid_pathways} -> + bad_request(conn, "Request must include a 'pathways' array.") + + {:error, :invalid_journal_entries} -> + bad_request(conn, "Request must include a 'journal_entries' array when provided.") + + {:error, :journal_batch_too_large} -> + bad_request(conn, "Request may include at most 100 journal entries.") + + {:error, :invalid_id} -> + bad_request(conn, "Invalid ID format.") + + {:error, :not_found} -> + not_found(conn) + end + end + + defp required_list(params, key) do + case Map.fetch(params, key) do + {:ok, values} when is_list(values) -> {:ok, values} + _ -> {:error, :invalid_pathways} + end + end + + defp optional_list(params, key) do + case Map.fetch(params, key) do + :error -> {:ok, nil} + {:ok, values} when is_list(values) -> {:ok, values} + _ -> {:error, :invalid_journal_entries} + end + end + + defp journal_batch_within_limit(nil), do: :ok + defp journal_batch_within_limit(entries) when length(entries) <= @max_journal_entries, do: :ok + defp journal_batch_within_limit(_entries), do: {:error, :journal_batch_too_large} + + defp resolve_scope(conn, %{"version_id" => version_id, "station_id" => station_id}) do + case Gtfs.resolve_station_journal_scope( + conn.assigns.current_organization_id, + version_id, + station_id, + conn.assigns.current_user_id + ) do + {:ok, scope} -> {:ok, scope} + {:error, :invalid_id} -> {:error, :invalid_id} + {:error, :not_found} -> {:error, :not_found} + end + end + + defp resolve_scope(_conn, _params), do: {:error, :invalid_id} + defp allowed_pathway_ids(scope) do + pathways_by_id = + scope.organization_id + |> Gtfs.list_pathways_for_station(scope.gtfs_version_id, scope.station_id) + |> Map.new(&{&1.id, &1}) + + {:ok, pathways_by_id} + end + + defp sync_pathways(updates, allowed_pathways) do results = - Enum.reduce(pathway_updates, %{synced: 0, errors: []}, fn update, acc -> - raw_id = update["id"] - - case Ecto.UUID.cast(raw_id) do - {:ok, pathway_id} -> - case Repo.get_by(Pathway, id: pathway_id, organization_id: org_id) do - nil -> - %{ - acc - | errors: [ - %{id: raw_id, code: "not_found", message: "Pathway not found."} | acc.errors - ] - } - - pathway -> - case endpoint_attrs(update, pathway) do - :invalid_endpoints -> - %{ - acc - | errors: [ - %{ - id: raw_id, - code: "invalid_endpoints", - message: - "from_stop_id/to_stop_id may only swap the pathway's own endpoints." - } - | acc.errors - ] - } - - {:ok, endpoint_changes} -> - attrs = - update - |> Map.take(Enum.map(@editable_fields, &Atom.to_string/1)) - |> Map.new(fn {k, v} -> {String.to_existing_atom(k), v} end) - |> Map.merge(endpoint_changes) - - changeset = Pathway.changeset(pathway, attrs) - - case Repo.update(changeset) do - {:ok, _} -> - %{acc | synced: acc.synced + 1} - - {:error, _changeset} -> - %{ - acc - | errors: [ - %{ - id: raw_id, - code: "validation_error", - message: "Failed to update pathway." - } - | acc.errors - ] - } - end - end - end - - :error -> - %{ - acc - | errors: [ - %{id: raw_id, code: "invalid_id", message: "Pathway id must be a valid UUID."} - | acc.errors - ] - } - end - end) - - response = %{ - data: %{ - synced_count: results.synced, - synced_at: DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() + Enum.reduce( + updates, + %{synced_count: 0, errors: [], pathways: allowed_pathways}, + &sync_pathway/2 + ) + + results + |> Map.update!(:errors, &Enum.reverse/1) + |> Map.delete(:pathways) + end + + defp sync_pathway(update, results) when is_map(update) do + raw_id = Map.get(update, "id") + + with {:ok, pathway_id} <- Ecto.UUID.cast(raw_id), + %Pathway{} = pathway <- Map.get(results.pathways, pathway_id), + {:ok, endpoint_changes} <- endpoint_attrs(update, pathway), + {:ok, updated_pathway} <- update_pathway(pathway, update, endpoint_changes) do + %{ + results + | synced_count: results.synced_count + 1, + pathways: Map.put(results.pathways, pathway_id, updated_pathway) } + else + :error -> + add_pathway_error(results, raw_id, "invalid_id", "Pathway id must be a valid UUID.") + + nil -> + add_pathway_error(results, raw_id, "not_found", "Pathway not found.") + + :invalid_endpoints -> + add_pathway_error( + results, + raw_id, + "invalid_endpoints", + "from_stop_id/to_stop_id may only swap the pathway's own endpoints." + ) + + {:error, :validation_error} -> + add_pathway_error(results, raw_id, "validation_error", "Failed to update pathway.") + end + end + + defp sync_pathway(_update, results), + do: add_pathway_error(results, nil, "validation_error", "Pathway update must be an object.") + + defp update_pathway(pathway, update, endpoint_changes) do + attrs = + update + |> Map.take(Enum.map(@editable_fields, &Atom.to_string/1)) + |> Map.new(fn {key, value} -> {String.to_existing_atom(key), value} end) + |> Map.merge(endpoint_changes) + + case Gtfs.update_pathway(pathway, attrs) do + {:ok, updated_pathway} -> {:ok, updated_pathway} + {:error, _changeset} -> {:error, :validation_error} + end + end + + defp sync_journal(_scope, nil), do: %{synced_count: 0, errors: []} + defp sync_journal(scope, entries), do: Gtfs.sync_journal_entries(scope, entries) + + defp sync_response(pathway_results, journal_results, journal_requested?) do + data = %{ + synced_count: pathway_results.synced_count, + synced_at: DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() } - response = - if results.errors != [] do - put_in(response, [:data, :errors], Enum.reverse(results.errors)) - else - response - end + data = + if journal_requested?, + do: Map.put(data, :journal_synced_count, journal_results.synced_count), + else: data - json(conn, response) + errors = pathway_results.errors ++ Enum.map(journal_results.errors, &journal_error/1) + data = if errors == [], do: data, else: Map.put(data, :errors, errors) + + %{data: data} end - def create(conn, _params) do - conn - |> put_status(400) - |> json(%{error: %{code: "bad_request", message: "Request must include a 'pathways' array."}}) + defp journal_error(%{id: id, code: code}) do + %{id: id, code: Atom.to_string(code), message: journal_error_message(code)} + end + + defp journal_error_message(:invalid_id), do: "Journal entry id must be a valid UUID." + + defp journal_error_message(:invalid_target), + do: "Journal entry target is invalid for this station." + + defp journal_error_message(:id_conflict), + do: "Journal entry id conflicts with an existing entry." + + defp journal_error_message(:validation_error), do: "Journal entry is invalid." + defp journal_error_message(_code), do: "Journal entry could not be synchronized." + + defp add_pathway_error(results, id, code, message) do + %{results | errors: [%{id: id, code: code, message: message} | results.errors]} end # Validates the swap-only endpoint rule. Returns {:ok, changes} where - # `changes` is empty (pair absent, or matches stored order — a no-op) or the - # swapped pair; :invalid_endpoints for anything else, including a partial - # pair. + # changes are empty (pair absent or unchanged) or the stored pair swapped. defp endpoint_attrs(update, pathway) do has_from = Map.has_key?(update, "from_stop_id") has_to = Map.has_key?(update, "to_stop_id") @@ -127,22 +196,36 @@ defmodule GtfsPlannerWeb.Api.V1.SyncController do not has_from and not has_to -> {:ok, %{}} - has_from and has_to -> - pair = {update["from_stop_id"], update["to_stop_id"]} - - cond do - pair == {pathway.from_stop_id, pathway.to_stop_id} -> - {:ok, %{}} + not (has_from and has_to) -> + :invalid_endpoints - pair == {pathway.to_stop_id, pathway.from_stop_id} -> - {:ok, %{from_stop_id: pathway.to_stop_id, to_stop_id: pathway.from_stop_id}} + {update["from_stop_id"], update["to_stop_id"]} == + {pathway.from_stop_id, pathway.to_stop_id} -> + {:ok, %{}} - true -> - :invalid_endpoints - end + {update["from_stop_id"], update["to_stop_id"]} == + {pathway.to_stop_id, pathway.from_stop_id} -> + {:ok, %{from_stop_id: pathway.to_stop_id, to_stop_id: pathway.from_stop_id}} true -> :invalid_endpoints end end + + defp bad_request(conn, message) do + conn + |> put_status(400) + |> json(%{ + error: %{ + code: "bad_request", + message: message + } + }) + end + + defp not_found(conn) do + conn + |> put_status(404) + |> json(%{error: %{code: "not_found"}}) + end end diff --git a/lib/gtfs_planner_web/endpoint.ex b/lib/gtfs_planner_web/endpoint.ex index 737e477b..13e940f0 100644 --- a/lib/gtfs_planner_web/endpoint.ex +++ b/lib/gtfs_planner_web/endpoint.ex @@ -48,7 +48,7 @@ defmodule GtfsPlannerWeb.Endpoint do plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] plug Plug.Parsers, - parsers: [:urlencoded, :multipart, :json], + parsers: [:urlencoded, GtfsPlannerWeb.Parsers.Multipart, :json], pass: ["*/*"], json_decoder: Phoenix.json_library() diff --git a/lib/gtfs_planner_web/parsers/multipart.ex b/lib/gtfs_planner_web/parsers/multipart.ex new file mode 100644 index 00000000..0d517997 --- /dev/null +++ b/lib/gtfs_planner_web/parsers/multipart.ex @@ -0,0 +1,67 @@ +defmodule GtfsPlannerWeb.Parsers.Multipart do + @moduledoc """ + Applies the larger multipart request budget exclusively to journal-photo uploads. + + All multipart parsing remains delegated to Plug's production parser. The route + check happens before routing because endpoint parsers run before the router. + """ + + @behaviour Plug.Parsers + + import Plug.Conn, only: [halt: 1, put_resp_content_type: 2, send_resp: 3] + + @multipart Plug.Parsers.MULTIPART + @default_limit 8_000_000 + @journal_photo_limit 26 * 1024 * 1024 + + @impl Plug.Parsers + def init(opts) do + {default_limit, opts} = Keyword.pop(opts, :default_length, @default_limit) + {journal_photo_limit, opts} = Keyword.pop(opts, :journal_length, @journal_photo_limit) + + %{ + default: @multipart.init(Keyword.put(opts, :length, default_limit)), + journal_photo: @multipart.init(Keyword.put(opts, :length, journal_photo_limit)) + } + end + + @impl Plug.Parsers + def parse(conn, type, subtype, headers, opts) do + journal_photo_request? = journal_photo_request?(conn) + parser_opts = if journal_photo_request?, do: opts.journal_photo, else: opts.default + + case @multipart.parse(conn, type, subtype, headers, parser_opts) do + {:error, :too_large, conn} = result -> + if journal_photo_request?, + do: {:ok, %{}, payload_too_large(conn)}, + else: result + + result -> + result + end + end + + defp journal_photo_request?(%Plug.Conn{ + method: "POST", + path_info: [ + "api", + "v1", + "versions", + _version_id, + "stations", + _station_id, + "journal-photos" + ] + }), + do: true + + defp journal_photo_request?(_conn), do: false + + defp payload_too_large(conn) do + conn + |> GtfsPlannerWeb.Plugs.CORS.call([]) + |> put_resp_content_type("application/json") + |> send_resp(413, Jason.encode!(%{error: %{code: "payload_too_large"}})) + |> halt() + end +end diff --git a/lib/gtfs_planner_web/plugs/assign_api_organization.ex b/lib/gtfs_planner_web/plugs/assign_api_organization.ex index 9711fa1d..b631c1e0 100644 --- a/lib/gtfs_planner_web/plugs/assign_api_organization.ex +++ b/lib/gtfs_planner_web/plugs/assign_api_organization.ex @@ -3,7 +3,8 @@ defmodule GtfsPlannerWeb.Plugs.AssignApiOrganization do Plug to resolve and assign the current organization for API requests. Reads `X-Organization-Id` header. If present, verifies the authenticated user - has a membership in that organization and assigns `:current_organization_id`. + has a membership in that organization and assigns `:current_organization_id` + and `:current_organization_membership`. If absent, falls back to the user's sole membership when they belong to exactly one organization. Multi-org users without the header receive a 403 listing available org IDs. Users with no memberships receive a 403. @@ -60,28 +61,30 @@ defmodule GtfsPlannerWeb.Plugs.AssignApiOrganization do end defp resolve_valid_org_id(conn, memberships, org_id) do - if Enum.any?(memberships, fn m -> m.organization_id == org_id end) do - assign(conn, :current_organization_id, org_id) - else - conn - |> put_resp_content_type("application/json") - |> send_resp( - 403, - Jason.encode!(%{ - error: %{ - code: "forbidden", - message: "You do not have access to this organization." - } - }) - ) - |> halt() + case Enum.find(memberships, &(&1.organization_id == org_id)) do + nil -> + conn + |> put_resp_content_type("application/json") + |> send_resp( + 403, + Jason.encode!(%{ + error: %{ + code: "forbidden", + message: "You do not have access to this organization." + } + }) + ) + |> halt() + + membership -> + assign_selected_membership(conn, membership) end end defp resolve_without_header(conn, memberships) do case memberships do [single] -> - assign(conn, :current_organization_id, single.organization_id) + assign_selected_membership(conn, single) [] -> conn @@ -115,4 +118,10 @@ defmodule GtfsPlannerWeb.Plugs.AssignApiOrganization do |> halt() end end + + defp assign_selected_membership(conn, membership) do + conn + |> assign(:current_organization_id, membership.organization_id) + |> assign(:current_organization_membership, membership) + end end diff --git a/lib/gtfs_planner_web/plugs/require_api_editor.ex b/lib/gtfs_planner_web/plugs/require_api_editor.ex new file mode 100644 index 00000000..f5b0c59c --- /dev/null +++ b/lib/gtfs_planner_web/plugs/require_api_editor.ex @@ -0,0 +1,40 @@ +defmodule GtfsPlannerWeb.Plugs.RequireApiEditor do + @moduledoc """ + Requires the selected companion-API organization membership to be an editor. + + `AssignApiOrganization` owns membership selection. This plug deliberately + authorizes only that exact active membership, never a role from another + organization a user may also belong to. + """ + + @behaviour Plug + + import Plug.Conn + + @editor_role "pathways_studio_editor" + + @impl true + def init(opts), do: opts + + @impl true + def call(conn, _opts) do + case conn.assigns[:current_organization_membership] do + %{roles: roles, deactivated_at: nil} when is_list(roles) -> + if @editor_role in roles do + conn + else + forbidden(conn) + end + + _ -> + forbidden(conn) + end + end + + defp forbidden(conn) do + conn + |> put_resp_content_type("application/json") + |> send_resp(403, Jason.encode!(%{error: %{code: "forbidden"}})) + |> halt() + end +end diff --git a/lib/gtfs_planner_web/plugs/uploads_plug.ex b/lib/gtfs_planner_web/plugs/uploads_plug.ex index a02eda79..536163eb 100644 --- a/lib/gtfs_planner_web/plugs/uploads_plug.ex +++ b/lib/gtfs_planner_web/plugs/uploads_plug.ex @@ -60,6 +60,7 @@ defmodule GtfsPlannerWeb.UploadsPlug do File.regular?(file_path_expanded) -> conn + |> put_field_capture_headers(rest) |> send_file(200, file_path_expanded) |> halt() @@ -67,4 +68,42 @@ defmodule GtfsPlannerWeb.UploadsPlug do conn end end + + # Field captures are the only files whose type and cache lifetime are a + # public API contract. Existing diagram and other upload delivery keeps its + # historical behavior, while this strict grammar prevents a filename from + # selecting headers for an arbitrary file under the uploads root. + defp put_field_capture_headers(conn, ["field-captures", organization_id, station_dir, filename]) do + case field_capture_type(organization_id, station_dir, filename) do + {:ok, content_type} -> + conn + |> put_resp_header("content-type", content_type) + |> put_resp_header("cache-control", "public, max-age=31536000, immutable") + |> put_resp_header("x-content-type-options", "nosniff") + + :error -> + conn + end + end + + defp put_field_capture_headers(conn, _rest), do: conn + + defp field_capture_type(organization_id, station_dir, filename) do + with true <- safe_component?(organization_id), + true <- safe_component?(station_dir), + [id, extension] <- String.split(filename, ".", parts: 2), + {:ok, ^id} <- Ecto.UUID.cast(id), + content_type when is_binary(content_type) <- extension_type(extension) do + {:ok, content_type} + else + _ -> :error + end + end + + defp safe_component?(value), + do: GtfsPlanner.Gtfs.Extensions.PathSafety.safe_path_component?(value) + + defp extension_type("jpg"), do: "image/jpeg" + defp extension_type("png"), do: "image/png" + defp extension_type(_extension), do: nil end diff --git a/lib/gtfs_planner_web/router.ex b/lib/gtfs_planner_web/router.ex index 8ad972c0..8c701e84 100644 --- a/lib/gtfs_planner_web/router.ex +++ b/lib/gtfs_planner_web/router.ex @@ -154,6 +154,10 @@ defmodule GtfsPlannerWeb.Router do plug GtfsPlannerWeb.Plugs.AssignApiOrganization end + pipeline :api_editor do + plug GtfsPlannerWeb.Plugs.RequireApiEditor + end + # -- Companion API routes ---------------------------------------------------- # CORS preflight — must be before authenticated routes @@ -179,7 +183,18 @@ defmodule GtfsPlannerWeb.Router do get "/versions", VersionController, :index get "/versions/:version_id/stations", StationController, :index get "/versions/:version_id/stations/:station_id/bundle", StationController, :bundle + end + + # Companion API writes require the role on the membership selected by the + # preceding API session pipeline. Reads above remain available to all members. + scope "/api/v1", GtfsPlannerWeb.Api.V1 do + pipe_through [:api_session, :api_editor] + post "/versions/:version_id/stations/:station_id/sync", SyncController, :create + + post "/versions/:version_id/stations/:station_id/journal-photos", + JournalPhotoController, + :create end # Enable LiveDashboard and Swoosh mailbox preview in development diff --git a/priv/repo/migrations/20260714000000_create_journal_entries.exs b/priv/repo/migrations/20260714000000_create_journal_entries.exs new file mode 100644 index 00000000..6b1206c7 --- /dev/null +++ b/priv/repo/migrations/20260714000000_create_journal_entries.exs @@ -0,0 +1,61 @@ +defmodule GtfsPlanner.Repo.Migrations.CreateJournalEntries do + use Ecto.Migration + + def change do + create table(:journal_entries, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :organization_id, + references(:organizations, type: :binary_id, on_delete: :delete_all), + null: false + + add :gtfs_version_id, + references(:gtfs_versions, type: :binary_id, on_delete: :delete_all), + null: false + + add :station_id, references(:stops, type: :binary_id, on_delete: :delete_all), null: false + add :author_id, :binary_id, null: false + add :target_type, :string, null: false + add :target_id, :binary_id + add :stop_level_id, :binary_id + add :diagram_x, :float + add :diagram_y, :float + add :body, :text + add :captured_at, :utc_datetime_usec, null: false + add :closed_at, :utc_datetime_usec + add :closed_by, :binary_id + add :lat, :float + add :lon, :float + + timestamps(type: :utc_datetime_usec) + end + + create index(:journal_entries, [:organization_id, :gtfs_version_id, :station_id], + name: :journal_entries_station_scope_index + ) + + create index(:journal_entries, [:station_id, :target_type, :target_id], + name: :journal_entries_target_index + ) + + create index(:journal_entries, [:station_id, :stop_level_id], + name: :journal_entries_stop_level_index + ) + + create constraint(:journal_entries, :journal_entries_target_shape_ck, + check: """ + (target_type = 'station' AND target_id IS NULL AND stop_level_id IS NULL AND diagram_x IS NULL AND diagram_y IS NULL) + OR (target_type IN ('node', 'pathway') AND target_id IS NOT NULL AND stop_level_id IS NULL AND diagram_x IS NULL AND diagram_y IS NULL) + OR (target_type = 'pin' AND target_id IS NULL AND stop_level_id IS NOT NULL + AND diagram_x IS NOT NULL AND diagram_y IS NOT NULL + AND diagram_x >= 0 AND diagram_y >= 0 + AND diagram_x <> 'NaN'::double precision AND diagram_y <> 'NaN'::double precision + AND diagram_x <> 'Infinity'::double precision AND diagram_y <> 'Infinity'::double precision) + """ + ) + + create constraint(:journal_entries, :journal_entries_closure_pair_ck, + check: "(closed_at IS NULL) = (closed_by IS NULL)" + ) + end +end diff --git a/priv/repo/migrations/20260714000001_create_journal_photos.exs b/priv/repo/migrations/20260714000001_create_journal_photos.exs new file mode 100644 index 00000000..4e2b4730 --- /dev/null +++ b/priv/repo/migrations/20260714000001_create_journal_photos.exs @@ -0,0 +1,43 @@ +defmodule GtfsPlanner.Repo.Migrations.CreateJournalPhotos do + use Ecto.Migration + + def change do + create table(:journal_photos, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :journal_entry_id, + references(:journal_entries, type: :binary_id, on_delete: :delete_all), + null: false + + add :filename, :string, null: false + add :content_type, :string, null: false + add :byte_size, :bigint, null: false + add :sha256, :binary, null: false + add :width, :integer + add :height, :integer + add :captured_at, :utc_datetime_usec, null: false + + timestamps(type: :utc_datetime_usec) + end + + create index(:journal_photos, [:journal_entry_id, :captured_at, :inserted_at, :id], + name: :journal_photos_entry_order_index + ) + + create constraint(:journal_photos, :journal_photos_content_type_ck, + check: "content_type IN ('image/jpeg', 'image/png')" + ) + + create constraint(:journal_photos, :journal_photos_byte_size_positive_ck, + check: "byte_size > 0" + ) + + create constraint(:journal_photos, :journal_photos_dimensions_positive_ck, + check: "(width IS NULL OR width > 0) AND (height IS NULL OR height > 0)" + ) + + create constraint(:journal_photos, :journal_photos_sha256_length_ck, + check: "octet_length(sha256) = 32" + ) + end +end diff --git a/test/gtfs_planner/gtfs/station_journal/photo_storage_test.exs b/test/gtfs_planner/gtfs/station_journal/photo_storage_test.exs new file mode 100644 index 00000000..079b2269 --- /dev/null +++ b/test/gtfs_planner/gtfs/station_journal/photo_storage_test.exs @@ -0,0 +1,448 @@ +defmodule GtfsPlanner.Gtfs.StationJournal.PhotoStorageTest do + use GtfsPlanner.DataCase, async: false + + import ExUnit.CaptureLog + import GtfsPlanner.GtfsFixtures + import GtfsPlanner.OrganizationsFixtures + import GtfsPlanner.VersionsFixtures + + alias GtfsPlanner.Gtfs + alias GtfsPlanner.Gtfs.{JournalEntry, JournalPhoto} + alias GtfsPlanner.Gtfs.Extensions.PathSafety + alias GtfsPlanner.Gtfs.StationJournal.{PhotoStorage, Scope} + alias GtfsPlanner.Repo + + @max_bytes 25 * 1024 * 1024 + @jpeg <<0xFF, 0xD8, "journal-photo", 0xFF, 0xD9>> + + @scope %Scope{ + organization_id: "e1d9aa70-532d-43e5-bab0-77cce113c923", + gtfs_version_id: "34247956-83fc-4e80-b0df-78f86972f5f9", + station_id: "9f7145c0-fd1a-4a82-bc54-0f4a70e147e9", + station_stop_id: "station/1", + actor_id: "a709799a-4b37-4af2-aa0a-9d8862da7f46" + } + + setup do + previous = Application.get_env(:gtfs_planner, :uploads_path) + + root = + Path.join( + System.tmp_dir!(), + "station_journal_photo_storage_#{System.unique_integer([:positive])}" + ) + + Application.put_env(:gtfs_planner, :uploads_path, root) + + on_exit(fn -> + File.rm_rf!(root) + + if is_nil(previous), + do: Application.delete_env(:gtfs_planner, :uploads_path), + else: Application.put_env(:gtfs_planner, :uploads_path, previous) + end) + + organization = organization_fixture() + version = gtfs_version_fixture(organization.id) + + station = + stop_fixture(organization.id, version.id, + stop_id: "station_#{System.unique_integer([:positive])}", + location_type: 1 + ) + + scope = %Scope{ + organization_id: organization.id, + gtfs_version_id: version.id, + station_id: station.id, + station_stop_id: station.stop_id, + actor_id: Ecto.UUID.generate() + } + + entry = + Repo.insert!(%JournalEntry{ + id: Ecto.UUID.generate(), + organization_id: organization.id, + gtfs_version_id: version.id, + station_id: station.id, + author_id: scope.actor_id, + target_type: "station", + captured_at: ~U[2026-07-13 10:00:00.000000Z] + }) + + %{root: root, scope: scope, entry: entry} + end + + test "stages boundary-valid JPEG and PNG bytes under deterministic trusted paths", %{root: root} do + jpg = write_upload(root, "capture.jpg", <<0xFF, 0xD8, 1, 2, 0xFF, 0xD9>>) + + png = + write_upload( + root, + "capture.png", + <<137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 0, "IEND", 174, 66, 96, 130>> + ) + + assert {:ok, staged_jpg} = + PhotoStorage.stage(@scope, "a1b2c3d4-e5f6-4789-8123-456789abcdef", %{path: jpg}) + + assert staged_jpg.content_type == "image/jpeg" + assert staged_jpg.filename == "a1b2c3d4-e5f6-4789-8123-456789abcdef.jpg" + + assert PhotoStorage.public_path(@scope, staged_jpg) =~ + "/field-captures/#{@scope.organization_id}/sid_" + + assert :ok = PhotoStorage.finalize(staged_jpg) + assert PhotoStorage.final_matches?(staged_jpg) + + assert {:ok, staged_png} = + PhotoStorage.stage(@scope, "b1b2c3d4-e5f6-4789-8123-456789abcdef", %{path: png}) + + assert staged_png.content_type == "image/png" + assert staged_png.filename == "b1b2c3d4-e5f6-4789-8123-456789abcdef.png" + assert :ok = PhotoStorage.discard(staged_png) + end + + test "rejects empty, truncated, unsupported, and unsafe inputs without a final file", %{ + root: root + } do + empty = write_upload(root, "empty", <<>>) + truncated = write_upload(root, "truncated", <<0xFF, 0xD8, 1>>) + unsupported = write_upload(root, "gif", "GIF89a") + + assert {:error, :empty_file} = + PhotoStorage.stage(@scope, Ecto.UUID.generate(), %{path: empty}) + + assert {:error, :invalid_image} = + PhotoStorage.stage(@scope, Ecto.UUID.generate(), %{path: truncated}) + + assert {:error, :invalid_image} = + PhotoStorage.stage(@scope, Ecto.UUID.generate(), %{path: unsupported}) + + assert {:error, :unsafe_path} = + PhotoStorage.stage(%{@scope | station_stop_id: nil}, Ecto.UUID.generate(), %{ + path: unsupported + }) + + assert [] == Path.wildcard(Path.join([root, "field-captures", "**", "*.jpg"])) + assert [] == Path.wildcard(Path.join([root, "field-captures", "**", "*.png"])) + end + + test "inspects a final artifact by digest and detects a corrupt replacement", %{root: root} do + upload = write_upload(root, "capture.jpg", <<0xFF, 0xD8, 1, 2, 0xFF, 0xD9>>) + assert {:ok, staged} = PhotoStorage.stage(@scope, Ecto.UUID.generate(), %{path: upload}) + assert :ok = PhotoStorage.finalize(staged) + assert PhotoStorage.final_matches?(staged) + assert :ok = File.write(staged.final_path, "wrong bytes") + refute PhotoStorage.final_matches?(staged) + end + + test "accepts exactly 25 MiB and rejects one byte more", %{root: root} do + exact = write_sparse_jpeg(root, "exact.jpg", @max_bytes) + oversized = write_sparse_jpeg(root, "oversized.jpg", @max_bytes + 1) + + assert {:ok, staged} = PhotoStorage.stage(@scope, Ecto.UUID.generate(), %{path: exact}) + assert staged.byte_size == @max_bytes + assert :ok = PhotoStorage.discard(staged) + + assert {:error, :payload_too_large} = + PhotoStorage.stage(@scope, Ecto.UUID.generate(), %{path: oversized}) + + assert [] == Path.wildcard(Path.join([root, "field-captures", "**", "*.tmp"])) + end + + test "cleans the current and stale temporary files after metadata validation failure", + context do + photo_id = Ecto.UUID.generate() + stale_path = stale_temp_path(context.root, context.scope, photo_id) + File.mkdir_p!(Path.dirname(stale_path)) + File.write!(stale_path, "stale") + + upload = upload(context.root, "mismatch.jpg", @jpeg) + + assert {:error, :validation_error} = + Gtfs.create_journal_photo( + context.scope, + photo_attrs(photo_id, context.entry.id, %{"content_type" => "image/png"}), + upload + ) + + assert [] == temporary_files(context.root, context.scope, photo_id) + refute Repo.get(JournalPhoto, photo_id) + end + + test "failed staging removes stale same-id temporary files", context do + photo_id = Ecto.UUID.generate() + stale_path = stale_temp_path(context.root, context.scope, photo_id) + File.mkdir_p!(Path.dirname(stale_path)) + File.write!(stale_path, "stale") + + invalid = upload(context.root, "invalid.jpg", "not an image") + + assert {:error, :invalid_image} = PhotoStorage.stage(context.scope, photo_id, invalid) + assert [] == temporary_files(context.root, context.scope, photo_id) + end + + test "temporary-file/no-row retry creates the row and removes stale same-id files", context do + photo_id = Ecto.UUID.generate() + stale_path = stale_temp_path(context.root, context.scope, photo_id) + File.mkdir_p!(Path.dirname(stale_path)) + File.write!(stale_path, "stale") + + assert {:ok, %JournalPhoto{id: ^photo_id}} = create_photo(context, photo_id, @jpeg) + assert [] == temporary_files(context.root, context.scope, photo_id) + assert File.read!(final_path(context.root, context.scope, photo_id)) == @jpeg + end + + test "final-file/no-row retry adopts matching bytes and cleans temporary files", context do + photo_id = Ecto.UUID.generate() + source = write_upload(context.root, "orphan.jpg", @jpeg) + assert {:ok, staged} = PhotoStorage.stage(context.scope, photo_id, %{path: source}) + assert :ok = PhotoStorage.finalize(staged) + refute Repo.get(JournalPhoto, photo_id) + + log = + capture_log([metadata: [:state]], fn -> + assert {:ok, %JournalPhoto{id: ^photo_id}} = create_photo(context, photo_id, @jpeg) + end) + + assert log =~ "orphan_adopted" + refute log =~ "journal-photo" + assert [] == temporary_files(context.root, context.scope, photo_id) + assert File.read!(staged.final_path) == @jpeg + end + + test "alternate-extension final-file/no-row retry returns a conflict", context do + photo_id = Ecto.UUID.generate() + + png = + <<137, 80, 78, 71, 13, 10, 26, 10, "orphan", 0, 0, 0, 0, "IEND", 174, 66, 96, 130>> + + source = write_upload(context.root, "orphan.png", png) + assert {:ok, staged} = PhotoStorage.stage(context.scope, photo_id, %{path: source}) + assert :ok = PhotoStorage.finalize(staged) + + assert {:error, :id_conflict} = create_photo(context, photo_id, @jpeg) + refute Repo.get(JournalPhoto, photo_id) + assert File.read!(staged.final_path) == png + refute File.exists?(final_path(context.root, context.scope, photo_id)) + end + + test "row/no-file retry restores the missing materialization", context do + photo_id = Ecto.UUID.generate() + assert {:ok, photo} = create_photo(context, photo_id, @jpeg) + path = final_path(context.root, context.scope, photo_id) + File.rm!(path) + + log = + capture_log([metadata: [:state]], fn -> + assert {:ok, ^photo} = create_photo(context, photo_id, @jpeg) + end) + + assert log =~ "file_repaired" + assert File.read!(path) == @jpeg + assert [] == temporary_files(context.root, context.scope, photo_id) + end + + test "row/correct-file retry returns the original row and removes stale temporary files", + context do + photo_id = Ecto.UUID.generate() + assert {:ok, photo} = create_photo(context, photo_id, @jpeg) + stale_path = stale_temp_path(context.root, context.scope, photo_id) + File.write!(stale_path, "stale") + + assert {:ok, ^photo} = create_photo(context, photo_id, @jpeg) + assert Repo.aggregate(JournalPhoto, :count, :id) == 1 + assert File.read!(final_path(context.root, context.scope, photo_id)) == @jpeg + assert [] == temporary_files(context.root, context.scope, photo_id) + end + + test "identical retry ignores changed optional metadata but still requires captured_at", + context do + photo_id = Ecto.UUID.generate() + assert {:ok, photo} = create_photo(context, photo_id, @jpeg) + + changed_metadata = + photo_attrs(photo_id, context.entry.id, %{ + "captured_at" => "2027-01-01T00:00:00Z", + "content_type" => "image/png", + "width" => -1, + "height" => "invalid" + }) + + assert {:ok, ^photo} = + Gtfs.create_journal_photo( + context.scope, + changed_metadata, + upload(context.root, "retry.jpg", @jpeg) + ) + + for captured_at <- [nil, "not-a-time"] do + attrs = photo_attrs(photo_id, context.entry.id, %{"captured_at" => captured_at}) + + assert {:error, :validation_error} = + Gtfs.create_journal_photo( + context.scope, + attrs, + upload(context.root, "invalid-retry.jpg", @jpeg) + ) + end + + assert Repo.get!(JournalPhoto, photo_id) == photo + end + + test "same-id staging serializes cleanup so one attempt cannot delete another", context do + photo_id = Ecto.UUID.generate() + first_source = write_upload(context.root, "first.jpg", @jpeg) + second_source = write_upload(context.root, "second.jpg", @jpeg) + parent = self() + + first = + start_supervised!(%{ + Task.child_spec(fn -> + {:ok, staged} = PhotoStorage.stage(context.scope, photo_id, %{path: first_source}) + send(parent, {:first_staged, self(), staged.path}) + + receive do + :finalize -> send(parent, {:first_finalized, PhotoStorage.finalize(staged)}) + end + end) + | id: make_ref() + }) + + assert_receive {:first_staged, ^first, first_path} + assert File.exists?(first_path) + + second = + start_supervised!(%{ + Task.child_spec(fn -> + send(parent, {:second_attempting, self()}) + result = PhotoStorage.stage(context.scope, photo_id, %{path: second_source}) + + result = + case result do + {:ok, staged} -> {result, PhotoStorage.discard(staged)} + error -> {error, :not_staged} + end + + send(parent, {:second_finished, self(), result}) + end) + | id: make_ref() + }) + + assert_receive {:second_attempting, ^second} + refute_receive {:second_finished, ^second, _result}, 50 + assert File.exists?(first_path) + + send(first, :finalize) + assert_receive {:first_finalized, :ok} + + # :global.set_lock/1 retries contention after a randomized backoff. + assert_receive {:second_finished, ^second, {{:ok, _staged}, :ok}}, 1_000 + end + + test "row/wrong-file retry repairs from bytes matching the authoritative row", context do + photo_id = Ecto.UUID.generate() + assert {:ok, photo} = create_photo(context, photo_id, @jpeg) + path = final_path(context.root, context.scope, photo_id) + File.write!(path, <<0xFF, 0xD8, "corrupt", 0xFF, 0xD9>>) + + log = + capture_log([metadata: [:state]], fn -> + assert {:ok, ^photo} = create_photo(context, photo_id, @jpeg) + end) + + assert log =~ "file_repaired" + refute log =~ "journal-photo" + assert File.read!(path) == @jpeg + assert [] == temporary_files(context.root, context.scope, photo_id) + end + + test "different bytes for an existing row return a conflict without leaking bytes", context do + photo_id = Ecto.UUID.generate() + assert {:ok, photo} = create_photo(context, photo_id, @jpeg) + changed = <<0xFF, 0xD8, "private-changed-bytes", 0xFF, 0xD9>> + + log = + capture_log([metadata: [:state]], fn -> + assert {:error, :id_conflict} = create_photo(context, photo_id, changed) + end) + + assert log =~ "id_conflict" + refute log =~ "private-changed-bytes" + assert Repo.get!(JournalPhoto, photo_id) == photo + assert File.read!(final_path(context.root, context.scope, photo_id)) == @jpeg + assert [] == temporary_files(context.root, context.scope, photo_id) + end + + defp write_upload(root, filename, bytes) do + path = Path.join(root, filename) + File.mkdir_p!(Path.dirname(path)) + File.write!(path, bytes) + path + end + + defp write_sparse_jpeg(root, filename, size) do + path = Path.join(root, filename) + File.mkdir_p!(Path.dirname(path)) + + {:ok, :ok} = + File.open(path, [:write, :binary], fn file -> + :ok = IO.binwrite(file, <<0xFF, 0xD8>>) + {:ok, _position} = :file.position(file, {:bof, size - 2}) + IO.binwrite(file, <<0xFF, 0xD9>>) + end) + + path + end + + defp create_photo(context, photo_id, bytes) do + Gtfs.create_journal_photo( + context.scope, + photo_attrs(photo_id, context.entry.id), + upload(context.root, "#{photo_id}.jpg", bytes) + ) + end + + defp photo_attrs(photo_id, entry_id, overrides \\ %{}) do + Map.merge( + %{ + "id" => photo_id, + "journal_entry_id" => entry_id, + "captured_at" => "2026-07-13T10:00:00Z" + }, + overrides + ) + end + + defp upload(root, filename, bytes) do + %{ + path: write_upload(root, "#{System.unique_integer([:positive])}-#{filename}", bytes), + filename: filename, + content_type: nil + } + end + + defp final_path(root, scope, photo_id) do + Path.join([ + root, + "field-captures", + scope.organization_id, + PathSafety.stop_storage_dir(scope.station_stop_id), + "#{photo_id}.jpg" + ]) + end + + defp stale_temp_path(root, scope, photo_id) do + final_path(root, scope, photo_id) + |> Path.rootname() + |> Kernel.<>(".#{photo_id}.stale.tmp") + end + + defp temporary_files(root, scope, photo_id) do + final_path(root, scope, photo_id) + |> Path.dirname() + |> Path.join("#{photo_id}.*.tmp") + |> Path.wildcard() + end +end diff --git a/test/gtfs_planner/gtfs/station_journal_test.exs b/test/gtfs_planner/gtfs/station_journal_test.exs new file mode 100644 index 00000000..3e76f888 --- /dev/null +++ b/test/gtfs_planner/gtfs/station_journal_test.exs @@ -0,0 +1,586 @@ +defmodule GtfsPlanner.Gtfs.StationJournalTest do + use GtfsPlanner.DataCase, async: false + + alias GtfsPlanner.Gtfs + alias GtfsPlanner.Gtfs.{JournalEntry, JournalPhoto} + alias GtfsPlanner.Gtfs.StationJournal.Scope + alias GtfsPlanner.Repo + + import GtfsPlanner.GtfsFixtures + import GtfsPlanner.OrganizationsFixtures + import GtfsPlanner.VersionsFixtures + + @scope %Scope{ + organization_id: "e1d9aa70-532d-43e5-bab0-77cce113c923", + gtfs_version_id: "34247956-83fc-4e80-b0df-78f86972f5f9", + station_id: "9f7145c0-fd1a-4a82-bc54-0f4a70e147e9", + station_stop_id: "station_1", + actor_id: "a709799a-4b37-4af2-aa0a-9d8862da7f46" + } + + @entry_id "1a4010de-2d4d-4c1a-86e3-03c9af2f5138" + @target_id "53cb9e6a-58c4-4b57-988a-d84cbfb459da" + @level_id "d3559e4e-4c49-4157-839d-fd8e5bcd4749" + @captured_at ~U[2026-07-13 12:00:00.123456Z] + + describe "JournalEntry.create_changeset/3" do + test "accepts every valid target shape with trusted scope and microsecond capture time" do + station = + JournalEntry.create_changeset( + %JournalEntry{}, + entry_attrs(%{target_type: "station"}), + @scope + ) + + node = + JournalEntry.create_changeset( + %JournalEntry{}, + entry_attrs(%{id: Ecto.UUID.generate(), target_type: "node", target_id: @target_id}), + @scope + ) + + pathway = + JournalEntry.create_changeset( + %JournalEntry{}, + entry_attrs(%{id: Ecto.UUID.generate(), target_type: "pathway", target_id: @target_id}), + @scope + ) + + pin = + JournalEntry.create_changeset( + %JournalEntry{}, + entry_attrs(%{ + id: Ecto.UUID.generate(), + target_type: "pin", + stop_level_id: @level_id, + diagram_x: 50.0, + diagram_y: 40.0 + }), + @scope + ) + + assert station.valid? + assert node.valid? + assert pathway.valid? + assert pin.valid? + assert Ecto.Changeset.get_change(station, :organization_id) == @scope.organization_id + assert Ecto.Changeset.get_change(station, :gtfs_version_id) == @scope.gtfs_version_id + assert Ecto.Changeset.get_change(station, :station_id) == @scope.station_id + assert Ecto.Changeset.get_change(station, :author_id) == @scope.actor_id + assert Ecto.Changeset.get_change(station, :captured_at).microsecond == {123_456, 6} + end + + test "rejects malformed target shapes" do + changeset = + JournalEntry.create_changeset( + %JournalEntry{}, + entry_attrs(%{ + target_type: "pin", + target_id: @target_id, + stop_level_id: @level_id, + diagram_x: -1.0, + diagram_y: 0.0 + }), + @scope + ) + + refute changeset.valid? + assert Keyword.has_key?(changeset.errors, :target_type) + end + + test "ignores client assignment of trusted scope, closure, and derived coordinates" do + changeset = + JournalEntry.create_changeset( + %JournalEntry{}, + entry_attrs(%{ + target_type: "station", + organization_id: Ecto.UUID.generate(), + gtfs_version_id: Ecto.UUID.generate(), + station_id: Ecto.UUID.generate(), + author_id: Ecto.UUID.generate(), + closed_at: @captured_at, + closed_by: Ecto.UUID.generate(), + lat: 1.0, + lon: 2.0 + }), + @scope + ) + + assert changeset.valid? + assert Ecto.Changeset.get_change(changeset, :organization_id) == @scope.organization_id + assert Ecto.Changeset.get_change(changeset, :gtfs_version_id) == @scope.gtfs_version_id + assert Ecto.Changeset.get_change(changeset, :station_id) == @scope.station_id + assert Ecto.Changeset.get_change(changeset, :author_id) == @scope.actor_id + refute Map.has_key?(changeset.changes, :closed_at) + refute Map.has_key?(changeset.changes, :lat) + refute Map.has_key?(changeset.changes, :lon) + end + end + + describe "JournalPhoto.create_changeset/2" do + test "requires valid immutable metadata" do + changeset = + JournalPhoto.create_changeset(%JournalPhoto{}, %{ + id: Ecto.UUID.generate(), + journal_entry_id: @entry_id, + filename: "photo.jpg", + content_type: "image/jpeg", + byte_size: 12, + sha256: :crypto.strong_rand_bytes(32), + width: 100, + height: 100, + captured_at: @captured_at + }) + + assert changeset.valid? + end + + test "rejects unsupported media, non-positive dimensions and size, and invalid digest length" do + changeset = + JournalPhoto.create_changeset(%JournalPhoto{}, %{ + id: Ecto.UUID.generate(), + journal_entry_id: @entry_id, + filename: "photo.gif", + content_type: "image/gif", + byte_size: 0, + sha256: <<1, 2>>, + width: 0, + height: -1, + captured_at: @captured_at + }) + + refute changeset.valid? + assert Keyword.has_key?(changeset.errors, :content_type) + assert Keyword.has_key?(changeset.errors, :byte_size) + assert Keyword.has_key?(changeset.errors, :sha256) + assert Keyword.has_key?(changeset.errors, :width) + assert Keyword.has_key?(changeset.errors, :height) + end + end + + describe "station journal scope and synchronization" do + setup do + organization = organization_fixture() + version = gtfs_version_fixture(organization.id) + + station = + stop_fixture(organization.id, version.id, + stop_id: "station_#{System.unique_integer([:positive])}", + location_type: 1 + ) + + child = + stop_fixture(organization.id, version.id, + stop_id: "platform_#{System.unique_integer([:positive])}", + parent_station: station.stop_id, + level_id: "L1" + ) + + pathway = pathway_fixture(organization.id, version.id, child.stop_id, child.stop_id) + level = level_fixture(organization.id, version.id, level_id: "L1") + + {:ok, stop_level} = + Gtfs.create_stop_level(%{ + organization_id: organization.id, + gtfs_version_id: version.id, + stop_id: station.id, + level_id: level.id + }) + + scope = %Scope{ + organization_id: organization.id, + gtfs_version_id: version.id, + station_id: station.id, + station_stop_id: station.stop_id, + actor_id: Ecto.UUID.generate() + } + + %{ + organization: organization, + version: version, + station: station, + child: child, + pathway: pathway, + stop_level: stop_level, + scope: scope + } + end + + test "resolves only a top-level station in the selected organization and version", %{ + organization: organization, + version: version, + station: station + } do + actor_id = Ecto.UUID.generate() + + assert {:ok, %Scope{station_id: station_id, station_stop_id: station_stop_id}} = + Gtfs.resolve_station_journal_scope( + organization.id, + version.id, + station.id, + actor_id + ) + + assert station_id == station.id + assert station_stop_id == station.stop_id + + assert {:error, :invalid_id} = + Gtfs.resolve_station_journal_scope("invalid", version.id, station.id, actor_id) + + assert {:error, :not_found} = + Gtfs.resolve_station_journal_scope( + organization.id, + version.id, + Ecto.UUID.generate(), + actor_id + ) + end + + test "synchronizes valid sibling targets and rejects invalid targets without rolling back", %{ + scope: scope, + child: child, + pathway: pathway, + stop_level: stop_level + } do + station_id = Ecto.UUID.generate() + node_id = Ecto.UUID.generate() + pin_id = Ecto.UUID.generate() + invalid_id = Ecto.UUID.generate() + + result = + Gtfs.sync_journal_entries(scope, [ + entry_attrs(%{id: station_id, target_type: "station"}), + entry_attrs(%{id: node_id, target_type: "node", target_id: child.id}), + entry_attrs(%{ + id: pin_id, + target_type: "pin", + stop_level_id: stop_level.id, + diagram_x: 12.0, + diagram_y: 8.0 + }), + entry_attrs(%{id: invalid_id, target_type: "pathway", target_id: Ecto.UUID.generate()}), + entry_attrs(%{ + id: Ecto.UUID.generate(), + target_type: "pathway", + target_id: pathway.id, + diagram_x: 1.0 + }) + ]) + + assert result.synced_count == 3 + assert [%{id: ^invalid_id, code: :invalid_target}, %{code: :invalid_target}] = result.errors + assert length(Gtfs.list_station_journal(scope)) == 3 + end + + test "keeps scope and audit fields immutable while same-scope replay updates body", %{ + scope: scope + } do + id = Ecto.UUID.generate() + captured_at = ~U[2026-07-13 12:00:00.123456Z] + + assert %{synced_count: 1, errors: []} = + Gtfs.sync_journal_entries(scope, [ + entry_attrs(%{ + id: id, + target_type: "station", + body: "first", + captured_at: captured_at + }) + ]) + + assert %{synced_count: 1, errors: []} = + Gtfs.sync_journal_entries(scope, [ + entry_attrs(%{ + id: id, + target_type: "station", + body: "last", + captured_at: DateTime.add(captured_at, 1, :second), + author_id: Ecto.UUID.generate() + }) + ]) + + [entry] = Gtfs.list_station_journal(scope) + assert entry.body == "last" + assert entry.captured_at == captured_at + assert entry.author_id == scope.actor_id + end + + test "accepts a partial same-scope replay without creation-only fields", %{scope: scope} do + id = Ecto.UUID.generate() + + assert %{synced_count: 1, errors: []} = + Gtfs.sync_journal_entries(scope, [ + %{ + "id" => id, + "target_type" => "station", + "body" => "first", + "captured_at" => "2026-07-13T12:00:00Z" + } + ]) + + assert %{synced_count: 1, errors: []} = + Gtfs.sync_journal_entries(scope, [%{"id" => id, "body" => "updated"}]) + + assert %JournalEntry{body: "updated", target_type: "station"} = Repo.get!(JournalEntry, id) + end + + test "concurrent same-scope UUID claims keep one owner and a committed mutable value", %{ + scope: scope + } do + id = Ecto.UUID.generate() + + requests = + for body <- ["first concurrent body", "second concurrent body"] do + {scope, entry_attrs(%{id: id, target_type: "station", body: body})} + end + + assert [ + %{synced_count: 1, errors: []}, + %{synced_count: 1, errors: []} + ] = run_concurrent_syncs(requests) + + assert %JournalEntry{ + organization_id: organization_id, + gtfs_version_id: gtfs_version_id, + station_id: station_id, + author_id: author_id, + body: body + } = Repo.get!(JournalEntry, id) + + assert organization_id == scope.organization_id + assert gtfs_version_id == scope.gtfs_version_id + assert station_id == scope.station_id + assert author_id == scope.actor_id + assert body in ["first concurrent body", "second concurrent body"] + assert Repo.aggregate(from(entry in JournalEntry, where: entry.id == ^id), :count) == 1 + end + + test "concurrent competing-scope UUID claims never transfer ownership", %{scope: scope} do + other_organization = organization_fixture() + other_version = gtfs_version_fixture(other_organization.id) + + other_station = + stop_fixture(other_organization.id, other_version.id, + stop_id: "station_#{System.unique_integer([:positive])}", + location_type: 1 + ) + + other_scope = %Scope{ + organization_id: other_organization.id, + gtfs_version_id: other_version.id, + station_id: other_station.id, + station_stop_id: other_station.stop_id, + actor_id: Ecto.UUID.generate() + } + + id = Ecto.UUID.generate() + + results = + run_concurrent_syncs([ + {scope, entry_attrs(%{id: id, target_type: "station", body: "first scope"})}, + {other_scope, entry_attrs(%{id: id, target_type: "station", body: "competing scope"})} + ]) + + assert Enum.count(results, &(&1 == %{synced_count: 1, errors: []})) == 1 + + assert Enum.count(results, fn result -> + result.synced_count == 0 and + result.errors == [%{id: id, code: :id_conflict}] + end) == 1 + + entry = Repo.get!(JournalEntry, id) + + assert {entry.organization_id, entry.gtfs_version_id, entry.station_id, entry.author_id, + entry.body} in [ + {scope.organization_id, scope.gtfs_version_id, scope.station_id, scope.actor_id, + "first scope"}, + {other_scope.organization_id, other_scope.gtfs_version_id, other_scope.station_id, + other_scope.actor_id, "competing scope"} + ] + + assert Repo.aggregate(from(entry in JournalEntry, where: entry.id == ^id), :count) == 1 + end + + test "reports non-object items without aborting valid siblings", %{scope: scope} do + id = Ecto.UUID.generate() + + assert %{ + synced_count: 1, + errors: [ + %{id: nil, code: :validation_error}, + %{id: nil, code: :validation_error} + ] + } = + Gtfs.sync_journal_entries(scope, [ + entry_attrs(%{id: id, target_type: "station"}), + "not-an-object", + nil + ]) + + assert %JournalEntry{id: ^id} = Repo.get(JournalEntry, id) + end + + test "does not transfer an entry UUID across station scopes", %{scope: scope} do + id = Ecto.UUID.generate() + + assert %{synced_count: 1, errors: []} = + Gtfs.sync_journal_entries(scope, [ + entry_attrs(%{id: id, target_type: "station", body: "original"}) + ]) + + other_organization = organization_fixture() + other_version = gtfs_version_fixture(other_organization.id) + + other_station = + stop_fixture(other_organization.id, other_version.id, + stop_id: "station_#{System.unique_integer([:positive])}", + location_type: 1 + ) + + other_scope = %Scope{ + organization_id: other_organization.id, + gtfs_version_id: other_version.id, + station_id: other_station.id, + station_stop_id: other_station.stop_id, + actor_id: Ecto.UUID.generate() + } + + assert %{synced_count: 0, errors: [%{id: ^id, code: :id_conflict}]} = + Gtfs.sync_journal_entries(other_scope, [ + entry_attrs(%{id: id, target_type: "station", body: "attempted transfer"}) + ]) + + [entry] = Gtfs.list_station_journal(scope) + assert entry.body == "original" + end + + test "returns entries and photos in deterministic capture order including closed history", %{ + scope: scope + } do + later_id = Ecto.UUID.generate() + earlier_id = Ecto.UUID.generate() + + assert %{synced_count: 2, errors: []} = + Gtfs.sync_journal_entries(scope, [ + entry_attrs(%{ + id: later_id, + target_type: "station", + captured_at: ~U[2026-07-13 12:01:00Z] + }), + entry_attrs(%{ + id: earlier_id, + target_type: "station", + captured_at: ~U[2026-07-13 12:00:00Z] + }) + ]) + + assert Enum.map(Gtfs.list_station_journal(scope), & &1.id) == [earlier_id, later_id] + end + + test "refreshes only scoped pin coordinates while preserving diagram coordinates", %{ + scope: scope, + stop_level: stop_level + } do + pin_id = Ecto.UUID.generate() + + assert %{synced_count: 1, errors: []} = + Gtfs.sync_journal_entries(scope, [ + entry_attrs(%{ + id: pin_id, + target_type: "pin", + stop_level_id: stop_level.id, + diagram_x: 50.0, + diagram_y: 40.0 + }) + ]) + + [unrefreshed] = Gtfs.list_station_journal(scope) + assert is_nil(unrefreshed.lat) + assert is_nil(unrefreshed.lon) + + {:ok, aligned_stop_level} = + Gtfs.update_stop_level_alignment(stop_level, %{ + floorplan_center_lat: 40.7128, + floorplan_center_lon: -74.006, + floorplan_scale_mpp: 0.25, + floorplan_rotation_deg: 0.0 + }) + + other_organization = organization_fixture() + other_version = gtfs_version_fixture(other_organization.id) + + other_station = + stop_fixture(other_organization.id, other_version.id, + stop_id: "station_#{System.unique_integer([:positive])}", + location_type: 1 + ) + + other_scope = %Scope{ + organization_id: other_organization.id, + gtfs_version_id: other_version.id, + station_id: other_station.id, + station_stop_id: other_station.stop_id, + actor_id: Ecto.UUID.generate() + } + + unrelated_pin = + JournalEntry.create_changeset( + %JournalEntry{}, + entry_attrs(%{ + id: Ecto.UUID.generate(), + target_type: "pin", + stop_level_id: stop_level.id, + diagram_x: 50.0, + diagram_y: 40.0 + }), + other_scope + ) + |> Repo.insert!() + + assert {:ok, 1} = Gtfs.refresh_pin_coordinates_for_stop_level(aligned_stop_level, 1000, 800) + + refreshed = Repo.get!(JournalEntry, pin_id) + assert refreshed.diagram_x == 50.0 + assert refreshed.diagram_y == 40.0 + assert_in_delta refreshed.lat, 40.7128, 1.0e-9 + assert_in_delta refreshed.lon, -74.006, 1.0e-9 + + assert %{lat: nil, lon: nil} = Repo.get!(JournalEntry, unrelated_pin.id) + end + end + + defp entry_attrs(attrs) do + Map.merge(%{id: @entry_id, captured_at: @captured_at}, attrs) + end + + defp run_concurrent_syncs(requests) do + parent = self() + + tasks = + Enum.map(requests, fn {scope, attrs} -> + start_supervised!(%{ + Task.child_spec(fn -> + send(parent, {:sync_ready, self()}) + + receive do + :sync -> + result = Gtfs.sync_journal_entries(scope, [attrs]) + send(parent, {:sync_finished, self(), result}) + end + end) + | id: make_ref() + }) + end) + + Enum.each(tasks, fn task -> + assert_receive {:sync_ready, ^task} + end) + + Enum.each(tasks, &send(&1, :sync)) + + Enum.map(tasks, fn task -> + assert_receive {:sync_finished, ^task, result} + result + end) + end +end diff --git a/test/gtfs_planner/gtfs_test.exs b/test/gtfs_planner/gtfs_test.exs index 86fa00fc..25feb95c 100644 --- a/test/gtfs_planner/gtfs_test.exs +++ b/test/gtfs_planner/gtfs_test.exs @@ -2,6 +2,8 @@ defmodule GtfsPlanner.GtfsTest do use GtfsPlanner.DataCase alias GtfsPlanner.Gtfs + alias GtfsPlanner.Gtfs.JournalEntry + alias GtfsPlanner.Gtfs.StationJournal.Scope alias GtfsPlanner.Gtfs.Level alias GtfsPlanner.Gtfs.StationEditingStatus alias GtfsPlanner.Gtfs.Stop @@ -3344,6 +3346,67 @@ defmodule GtfsPlanner.GtfsTest do assert_in_delta Decimal.to_float(reloaded_child_stop.stop_lon), -74.0060, 1.0e-9 end + test "refreshes pin geography on alignment and re-alignment without changing its return shape", + %{ + organization: organization, + gtfs_version: version, + station: station, + stop_level: stop_level + } do + scope = %Scope{ + organization_id: organization.id, + gtfs_version_id: version.id, + station_id: station.id, + station_stop_id: station.stop_id, + actor_id: Ecto.UUID.generate() + } + + pin_id = Ecto.UUID.generate() + + assert %{synced_count: 1, errors: []} = + Gtfs.sync_journal_entries(scope, [ + %{ + id: pin_id, + target_type: "pin", + stop_level_id: stop_level.id, + diagram_x: 50.0, + diagram_y: 40.0, + captured_at: ~U[2026-07-13 12:00:00Z] + } + ]) + + assert %{lat: nil, lon: nil} = Repo.get!(JournalEntry, pin_id) + + attrs = %{ + floorplan_center_lat: 40.7128, + floorplan_center_lon: -74.0060, + floorplan_scale_mpp: 0.25, + floorplan_rotation_deg: 0.0 + } + + assert {:ok, %{active_stop_level: updated, apply_result: %{touched_stop_count: 0}}} = + Gtfs.save_and_apply_stop_level_alignment(stop_level.id, attrs, 1000, 800) + + refreshed = Repo.get!(JournalEntry, pin_id) + assert refreshed.diagram_x == 50.0 + assert refreshed.diagram_y == 40.0 + assert_in_delta refreshed.lat, 40.7128, 1.0e-9 + assert_in_delta refreshed.lon, -74.0060, 1.0e-9 + + realigned_attrs = %{attrs | floorplan_center_lat: 40.7138} + updated_id = updated.id + + assert {:ok, + %{active_stop_level: %{id: ^updated_id}, apply_result: %{touched_stop_count: 0}}} = + Gtfs.save_and_apply_stop_level_alignment(stop_level.id, realigned_attrs, 1000, 800) + + realigned = Repo.get!(JournalEntry, pin_id) + assert realigned.diagram_x == 50.0 + assert realigned.diagram_y == 40.0 + assert_in_delta realigned.lat, 40.7138, 1.0e-9 + assert_in_delta realigned.lon, -74.0060, 1.0e-9 + end + test "returns :not_found when active stop level does not exist" do assert {:error, :not_found} = Gtfs.save_and_apply_stop_level_alignment( diff --git a/test/gtfs_planner_web/api/v1/auth_controller_test.exs b/test/gtfs_planner_web/api/v1/auth_controller_test.exs index b8c1022d..ff4337b8 100644 --- a/test/gtfs_planner_web/api/v1/auth_controller_test.exs +++ b/test/gtfs_planner_web/api/v1/auth_controller_test.exs @@ -53,7 +53,7 @@ defmodule GtfsPlannerWeb.Api.V1.AuthControllerTest do describe "login/2" do setup [:setup_user_with_org] - test "returns 200 with token, user, organization_id, and expires_at for valid credentials", + test "returns 200 with token, user, organization membership, and expires_at for valid credentials", %{conn: conn, user: user, org: org} do conn = call_login(conn, %{"email" => user.email, "password" => @password}) @@ -65,6 +65,7 @@ defmodule GtfsPlannerWeb.Api.V1.AuthControllerTest do assert data["user"]["id"] == user.id assert data["user"]["email"] == user.email assert data["organization_id"] == org.id + assert data["roles"] == ["pathways_studio_editor"] assert is_binary(data["expires_at"]) # Verify expires_at is roughly 60 days from now diff --git a/test/gtfs_planner_web/api/v1/journal_photo_controller_test.exs b/test/gtfs_planner_web/api/v1/journal_photo_controller_test.exs new file mode 100644 index 00000000..a375bf7d --- /dev/null +++ b/test/gtfs_planner_web/api/v1/journal_photo_controller_test.exs @@ -0,0 +1,240 @@ +defmodule GtfsPlannerWeb.Api.V1.JournalPhotoControllerTest do + use GtfsPlannerWeb.ConnCase, async: false + + import GtfsPlanner.AccountsFixtures + import GtfsPlanner.GtfsFixtures + import GtfsPlanner.OrganizationsFixtures + import GtfsPlanner.VersionsFixtures + + alias GtfsPlanner.Accounts + alias GtfsPlanner.Gtfs.JournalEntry + alias GtfsPlanner.Repo + alias GtfsPlannerWeb.Api.V1.JournalPhotoController + + setup_all do + previous = Application.get_env(:gtfs_planner, :uploads_path) + + on_exit(fn -> + if is_nil(previous), + do: Application.delete_env(:gtfs_planner, :uploads_path), + else: Application.put_env(:gtfs_planner, :uploads_path, previous) + end) + + :ok + end + + setup %{conn: conn} do + uploads_path = + Path.join( + System.tmp_dir!(), + "journal_photo_controller_#{System.unique_integer([:positive])}" + ) + + Application.put_env(:gtfs_planner, :uploads_path, uploads_path) + File.mkdir_p!(uploads_path) + + user = user_fixture() + org = organization_fixture() + version = gtfs_version_fixture(org.id) + station = stop_fixture(org.id, version.id, %{location_type: 1, parent_station: nil}) + + {:ok, _membership} = + Accounts.create_user_org_membership(%{ + user_id: user.id, + organization_id: org.id, + roles: ["pathways_studio_editor"] + }) + + entry = + Repo.insert!(%JournalEntry{ + id: Ecto.UUID.generate(), + organization_id: org.id, + gtfs_version_id: version.id, + station_id: station.id, + author_id: user.id, + target_type: "station", + captured_at: ~U[2026-07-13 10:00:00.000000Z] + }) + + on_exit(fn -> File.rm_rf!(uploads_path) end) + + %{ + conn: + Plug.Conn.assign(conn, :current_organization_id, org.id) + |> Plug.Conn.assign(:current_user_id, user.id), + version: version, + station: station, + entry: entry, + uploads_path: uploads_path + } + end + + test "creates a JPEG from object metadata with the shared public representation", context do + photo_id = Ecto.UUID.generate() + upload = upload(context.uploads_path, "capture.jpg", <<0xFF, 0xD8, "journal", 0xFF, 0xD9>>) + + conn = + JournalPhotoController.create(context.conn, %{ + "version_id" => context.version.id, + "station_id" => context.station.id, + "metadata" => metadata(photo_id, context.entry.id), + "file" => upload + }) + + assert %{"data" => %{"photo" => photo}} = json_response(conn, 201) + + assert photo == %{ + "id" => photo_id, + "journal_entry_id" => context.entry.id, + "url" => photo["url"], + "content_type" => "image/jpeg", + "width" => nil, + "height" => nil, + "captured_at" => "2026-07-13T10:00:00.000000Z" + } + + assert photo["url"] =~ + "/uploads/field-captures/#{context.conn.assigns.current_organization_id}/" + end + + test "accepts JSON-string metadata and returns the original representation on identical retry", + context do + photo_id = Ecto.UUID.generate() + bytes = <<137, 80, 78, 71, 13, 10, 26, 10, "journal", 0, 0, 0, 0, "IEND", 174, 66, 96, 130>> + first_upload = upload(context.uploads_path, "first.png", bytes) + + params = %{ + "version_id" => context.version.id, + "station_id" => context.station.id, + "metadata" => Jason.encode!(metadata(photo_id, context.entry.id, %{width: 10})), + "file" => first_upload + } + + first = JournalPhotoController.create(context.conn, params) + assert %{"data" => %{"photo" => first_photo}} = json_response(first, 201) + + retry_upload = upload(context.uploads_path, "retry.png", bytes) + + retry = + JournalPhotoController.create(context.conn, %{ + params + | "metadata" => + Jason.encode!(metadata(photo_id, context.entry.id, %{width: 20, height: 30})), + "file" => retry_upload + }) + + assert %{"data" => %{"photo" => ^first_photo}} = json_response(retry, 201) + end + + test "maps invalid URL, missing scope, conflicts, size, and validation failures", context do + upload = upload(context.uploads_path, "capture.jpg", <<0xFF, 0xD8, 0xFF, 0xD9>>) + photo_id = Ecto.UUID.generate() + + invalid_url = + JournalPhotoController.create(context.conn, %{ + "version_id" => "not-a-uuid", + "station_id" => context.station.id, + "metadata" => metadata(photo_id, context.entry.id), + "file" => upload + }) + + assert %{"error" => %{"code" => "bad_request"}} = json_response(invalid_url, 400) + + missing_entry = + JournalPhotoController.create(context.conn, %{ + "version_id" => context.version.id, + "station_id" => context.station.id, + "metadata" => metadata(Ecto.UUID.generate(), Ecto.UUID.generate()), + "file" => upload + }) + + assert %{"error" => %{"code" => "not_found"}} = json_response(missing_entry, 404) + + invalid_image = + JournalPhotoController.create(context.conn, %{ + "version_id" => context.version.id, + "station_id" => context.station.id, + "metadata" => metadata(Ecto.UUID.generate(), context.entry.id), + "file" => upload(context.uploads_path, "invalid.txt", "not an image") + }) + + assert %{"error" => %{"code" => "validation_error"}} = json_response(invalid_image, 422) + + conflict_id = Ecto.UUID.generate() + + first_conflict = + JournalPhotoController.create(context.conn, %{ + "version_id" => context.version.id, + "station_id" => context.station.id, + "metadata" => metadata(conflict_id, context.entry.id), + "file" => upload(context.uploads_path, "first.jpg", <<0xFF, 0xD8, "first", 0xFF, 0xD9>>) + }) + + assert %{"data" => %{"photo" => %{"id" => ^conflict_id}}} = + json_response(first_conflict, 201) + + conflict = + JournalPhotoController.create(context.conn, %{ + "version_id" => context.version.id, + "station_id" => context.station.id, + "metadata" => metadata(conflict_id, context.entry.id), + "file" => + upload(context.uploads_path, "changed.jpg", <<0xFF, 0xD8, "changed", 0xFF, 0xD9>>) + }) + + assert %{"error" => %{"code" => "id_conflict"}} = json_response(conflict, 409) + + oversized_path = Path.join(context.uploads_path, "oversized.jpg") + + {:ok, :ok} = + File.open(oversized_path, [:write, :binary], fn file -> + :ok = IO.binwrite(file, <<0xFF, 0xD8>>) + {:ok, _position} = :file.position(file, {:bof, 25 * 1024 * 1024 - 1}) + IO.binwrite(file, <<0xFF, 0xD9>>) + end) + + oversized = + JournalPhotoController.create(context.conn, %{ + "version_id" => context.version.id, + "station_id" => context.station.id, + "metadata" => metadata(Ecto.UUID.generate(), context.entry.id), + "file" => %Plug.Upload{path: oversized_path, filename: "oversized.jpg", content_type: nil} + }) + + assert %{"error" => %{"code" => "payload_too_large"}} = json_response(oversized, 413) + end + + test "maps storage failures to an internal storage error", context do + upload = upload(context.uploads_path, "capture.jpg", <<0xFF, 0xD8, 0xFF, 0xD9>>) + blocking_path = Path.join(context.uploads_path, "blocking-file") + File.write!(blocking_path, "not a directory") + Application.put_env(:gtfs_planner, :uploads_path, blocking_path) + + conn = + JournalPhotoController.create(context.conn, %{ + "version_id" => context.version.id, + "station_id" => context.station.id, + "metadata" => metadata(Ecto.UUID.generate(), context.entry.id), + "file" => upload + }) + + assert %{"error" => %{"code" => "storage_error"}} = json_response(conn, 500) + end + + defp metadata(photo_id, entry_id, overrides \\ %{}) do + Map.merge( + %{ + "id" => photo_id, + "journal_entry_id" => entry_id, + "captured_at" => "2026-07-13T10:00:00Z" + }, + overrides + ) + end + + defp upload(directory, filename, bytes) do + path = Path.join(directory, "#{System.unique_integer([:positive])}-#{filename}") + File.write!(path, bytes) + %Plug.Upload{path: path, filename: filename, content_type: nil} + end +end diff --git a/test/gtfs_planner_web/api/v1/station_controller_test.exs b/test/gtfs_planner_web/api/v1/station_controller_test.exs index 311d1659..2c9deff5 100644 --- a/test/gtfs_planner_web/api/v1/station_controller_test.exs +++ b/test/gtfs_planner_web/api/v1/station_controller_test.exs @@ -7,7 +7,7 @@ defmodule GtfsPlannerWeb.Api.V1.StationControllerTest do import GtfsPlanner.GtfsFixtures alias GtfsPlanner.Accounts - alias GtfsPlanner.Gtfs.StopLevel + alias GtfsPlanner.Gtfs.{JournalEntry, JournalPhoto, StopLevel} alias GtfsPlanner.Repo # --------------------------------------------------------------------------- @@ -65,6 +65,20 @@ defmodule GtfsPlannerWeb.Api.V1.StationControllerTest do %{station: station, level: level, child1: child1, child2: child2, pathway: pathway} end + defp journal_entry_fixture(org, version, station, user, attrs) do + defaults = %{ + id: Ecto.UUID.generate(), + organization_id: org.id, + gtfs_version_id: version.id, + station_id: station.id, + author_id: user.id, + target_type: "station", + captured_at: ~U[2026-07-13 10:00:00.000000Z] + } + + Repo.insert!(struct!(JournalEntry, Map.merge(defaults, attrs))) + end + # --------------------------------------------------------------------------- # GET /api/v1/versions/:version_id/stations (index) # --------------------------------------------------------------------------- @@ -333,6 +347,140 @@ defmodule GtfsPlannerWeb.Api.V1.StationControllerTest do assert is_binary(data["downloaded_at"]) end + test "nests scoped journal history with ordered photos at documented bundle targets", %{ + conn: conn, + user: user, + org: org + } do + version = gtfs_version_fixture(org.id) + + %{station: station, level: level, child1: child1, pathway: pathway} = + build_station_data(org.id, version.id) + + {:ok, stop_level} = + %StopLevel{ + stop_id: station.id, + level_id: level.id, + organization_id: org.id, + gtfs_version_id: version.id + } + |> Repo.insert() + + station_entry = + journal_entry_fixture(org, version, station, user, %{ + body: "station entry", + captured_at: ~U[2026-07-13 09:00:00.000000Z], + closed_at: ~U[2026-07-13 09:30:00.000000Z], + closed_by: user.id + }) + + _node_entry = + journal_entry_fixture(org, version, station, user, %{ + target_type: "node", + target_id: child1.id, + body: "node entry" + }) + + _pathway_entry = + journal_entry_fixture(org, version, station, user, %{ + target_type: "pathway", + target_id: pathway.id, + body: "pathway entry" + }) + + _pin_entry = + journal_entry_fixture(org, version, station, user, %{ + target_type: "pin", + stop_level_id: stop_level.id, + diagram_x: 12.5, + diagram_y: 40.0, + lat: 39.95, + lon: -75.16, + body: "pin entry" + }) + + earlier_photo = + Repo.insert!(%JournalPhoto{ + id: Ecto.UUID.generate(), + journal_entry_id: station_entry.id, + filename: "#{Ecto.UUID.generate()}.jpg", + content_type: "image/jpeg", + byte_size: 3, + sha256: :crypto.strong_rand_bytes(32), + captured_at: ~U[2026-07-13 08:00:00.000000Z] + }) + + later_photo = + Repo.insert!(%JournalPhoto{ + id: Ecto.UUID.generate(), + journal_entry_id: station_entry.id, + filename: "#{Ecto.UUID.generate()}.png", + content_type: "image/png", + byte_size: 3, + sha256: :crypto.strong_rand_bytes(32), + captured_at: ~U[2026-07-13 11:00:00.000000Z] + }) + + conn = + conn + |> authed_conn(user) + |> get("/api/v1/versions/#{version.id}/stations/#{station.id}/bundle") + + assert %{"data" => data} = json_response(conn, 200) + assert [station_json] = data["journal_entries"] + assert station_json["id"] == station_entry.id + assert station_json["closed_by"] == user.id + assert [photo, second_photo] = station_json["photos"] + assert photo["id"] == earlier_photo.id + assert second_photo["id"] == later_photo.id + + assert String.starts_with?( + photo["url"], + "#{GtfsPlannerWeb.Endpoint.url()}/uploads/field-captures/#{org.id}/" + ) + + assert String.ends_with?(photo["url"], ".jpg") + + assert [node_json] = Enum.find(data["stops"], &(&1["id"] == child1.id))["journal_entries"] + assert node_json["body"] == "node entry" + + assert [pathway_json] = + Enum.find(data["pathways"], &(&1["id"] == pathway.id))["journal_entries"] + + assert pathway_json["body"] == "pathway entry" + + level_json = Enum.find(data["levels"], &(&1["id"] == level.id)) + assert level_json["stop_level_id"] == stop_level.id + assert [pin_json] = level_json["journal_entries"] + assert pin_json["diagram_coordinate"] == %{"x" => 12.5, "y" => 40.0} + assert pin_json["lat"] == 39.95 + assert pin_json["lon"] == -75.16 + end + + test "omits journal history whose target no longer appears in the bundle", %{ + conn: conn, + user: user, + org: org + } do + version = gtfs_version_fixture(org.id) + %{station: station, child1: child1} = build_station_data(org.id, version.id) + + _orphaned_node_entry = + journal_entry_fixture(org, version, station, user, %{ + target_type: "node", + target_id: Ecto.UUID.generate(), + body: "deleted node history" + }) + + conn = + conn + |> authed_conn(user) + |> get("/api/v1/versions/#{version.id}/stations/#{station.id}/bundle") + + assert %{"data" => data} = json_response(conn, 200) + assert Enum.find(data["stops"], &(&1["id"] == child1.id))["journal_entries"] == [] + end + test "level floorplan carries url + alignment when the stop_level is aligned", %{ conn: conn, user: user, diff --git a/test/gtfs_planner_web/api/v1/station_journal_integration_test.exs b/test/gtfs_planner_web/api/v1/station_journal_integration_test.exs new file mode 100644 index 00000000..5e387d8e --- /dev/null +++ b/test/gtfs_planner_web/api/v1/station_journal_integration_test.exs @@ -0,0 +1,428 @@ +defmodule GtfsPlannerWeb.Api.V1.StationJournalIntegrationTest do + use GtfsPlannerWeb.ConnCase, async: false + + import GtfsPlanner.AccountsFixtures + import GtfsPlanner.GtfsFixtures + import GtfsPlanner.OrganizationsFixtures + import GtfsPlanner.VersionsFixtures + + alias GtfsPlanner.Accounts + alias GtfsPlanner.Gtfs + + @password "valid user password 123456" + + setup_all do + previous_uploads_path = Application.get_env(:gtfs_planner, :uploads_path) + + on_exit(fn -> + if previous_uploads_path do + Application.put_env(:gtfs_planner, :uploads_path, previous_uploads_path) + else + Application.delete_env(:gtfs_planner, :uploads_path) + end + end) + + :ok + end + + setup do + uploads_path = + Path.join( + System.tmp_dir!(), + "station_journal_integration_#{System.unique_integer([:positive])}" + ) + + Application.put_env(:gtfs_planner, :uploads_path, uploads_path) + File.mkdir_p!(uploads_path) + + on_exit(fn -> File.rm_rf!(uploads_path) end) + + %{uploads_path: uploads_path} + end + + test "mobile consumer logs in, discovers, syncs, uploads, retries, and refreshes its bundle", %{ + conn: conn + } do + %{user: user, organization: organization, version: version, station: station} = + editor_station_fixture() + + login = post(conn, "/api/v1/auth/login", %{"email" => user.email, "password" => @password}) + + assert %{"data" => %{"token" => token, "organization_id" => organization_id}} = + json_response(login, 200) + + assert organization_id == organization.id + + versions = get(api_conn(conn, token, organization_id), "/api/v1/versions") + assert %{"data" => versions_data} = json_response(versions, 200) + assert %{"id" => version_id} = Enum.find(versions_data, &(&1["id"] == version.id)) + + stations = + get(api_conn(conn, token, organization_id), "/api/v1/versions/#{version_id}/stations") + + assert %{"data" => stations_data} = json_response(stations, 200) + assert %{"id" => station_id} = Enum.find(stations_data, &(&1["id"] == station.id)) + + initial_bundle = + get(api_conn(conn, token, organization_id), bundle_url(version_id, station_id)) + + assert %{"data" => initial_data} = json_response(initial_bundle, 200) + + [node | _] = initial_data["stops"] + [pathway | _] = initial_data["pathways"] + level = Enum.find(initial_data["levels"], &is_binary(&1["stop_level_id"])) + + entries = [ + entry_payload("station"), + entry_payload("node", %{"target_id" => node["id"]}), + entry_payload("pathway", %{"target_id" => pathway["id"]}), + entry_payload("pin", %{ + "stop_level_id" => level["stop_level_id"], + "diagram_x" => 50.0, + "diagram_y" => 40.0 + }) + ] + + sync = + post(api_conn(conn, token, organization_id), sync_url(version_id, station_id), %{ + "pathways" => [], + "journal_entries" => entries + }) + + assert %{"data" => %{"synced_count" => 0, "journal_synced_count" => 4}} = + json_response(sync, 200) + + station_entry = Enum.find(entries, &(&1["target_type"] == "station")) + photo_id = Ecto.UUID.generate() + + metadata = %{ + "id" => photo_id, + "journal_entry_id" => station_entry["id"], + "captured_at" => "2026-07-13T10:00:00Z", + "width" => 1, + "height" => 1 + } + + upload = + post_multipart( + api_conn(conn, token, organization_id), + photo_url(version_id, station_id), + metadata, + jpeg() + ) + + assert %{"data" => %{"photo" => photo}} = json_response(upload, 201) + + assert photo == %{ + "id" => photo_id, + "journal_entry_id" => station_entry["id"], + "url" => photo["url"], + "content_type" => "image/jpeg", + "width" => 1, + "height" => 1, + "captured_at" => "2026-07-13T10:00:00.000000Z" + } + + public_url = photo["url"] + + public_get = get(build_conn(), URI.parse(public_url).path) + assert public_get.status == 200 + assert get_resp_header(public_get, "content-type") == ["image/jpeg"] + assert get_resp_header(public_get, "cache-control") == ["public, max-age=31536000, immutable"] + assert get_resp_header(public_get, "x-content-type-options") == ["nosniff"] + assert public_get.resp_body == jpeg() + + refreshed = get(api_conn(conn, token, organization_id), bundle_url(version_id, station_id)) + assert %{"data" => refreshed_data} = json_response(refreshed, 200) + + station_json = + expected_entry(station_entry, user.id, %{"target_id" => nil, "photos" => [photo]}) + + node_entry = Enum.find(entries, &(&1["target_type"] == "node")) + pathway_entry = Enum.find(entries, &(&1["target_type"] == "pathway")) + pin_entry = Enum.find(entries, &(&1["target_type"] == "pin")) + + node_json = expected_entry(node_entry, user.id, %{"target_id" => node["id"]}) + + pathway_json = + expected_entry(pathway_entry, user.id, %{"target_id" => pathway["id"]}) + + pin_json = + expected_entry(pin_entry, user.id, %{ + "stop_level_id" => level["stop_level_id"], + "diagram_coordinate" => %{"x" => 50.0, "y" => 40.0}, + "lat" => nil, + "lon" => nil + }) + + assert refreshed_data["journal_entries"] == [station_json] + + assert Enum.find(refreshed_data["stops"], &(&1["id"] == node["id"]))[ + "journal_entries" + ] == [node_json] + + assert Enum.find(refreshed_data["pathways"], &(&1["id"] == pathway["id"]))[ + "journal_entries" + ] == [pathway_json] + + assert Enum.find( + refreshed_data["levels"], + &(&1["stop_level_id"] == level["stop_level_id"]) + )["journal_entries"] == [pin_json] + + assert Enum.sort(journal_ids(refreshed_data)) == Enum.sort(Enum.map(entries, & &1["id"])) + + retry_sync = + post(api_conn(conn, token, organization_id), sync_url(version_id, station_id), %{ + "pathways" => [], + "journal_entries" => entries + }) + + assert %{"data" => %{"journal_synced_count" => 4}} = json_response(retry_sync, 200) + + retry_upload = + post_multipart( + api_conn(conn, token, organization_id), + photo_url(version_id, station_id), + Map.merge(metadata, %{"width" => 999, "height" => 999}), + jpeg() + ) + + assert %{"data" => %{"photo" => ^photo}} = json_response(retry_upload, 201) + + retried_bundle = + get(api_conn(conn, token, organization_id), bundle_url(version_id, station_id)) + + assert %{"data" => retried_data} = json_response(retried_bundle, 200) + + assert retried_data["journal_entries"] == [station_json] + + assert Enum.find(retried_data["stops"], &(&1["id"] == node["id"]))[ + "journal_entries" + ] == [node_json] + + assert Enum.find(retried_data["pathways"], &(&1["id"] == pathway["id"]))[ + "journal_entries" + ] == [pathway_json] + + assert Enum.find( + retried_data["levels"], + &(&1["stop_level_id"] == level["stop_level_id"]) + )["journal_entries"] == [pin_json] + + assert Enum.sort(journal_ids(retried_data)) == + Enum.sort(Enum.map(entries, & &1["id"])) + end + + test "a viewer can read the selected organization bundle but cannot sync or upload", %{ + conn: conn + } do + %{organization: organization, version: version, station: station} = editor_station_fixture() + viewer = user_fixture() + + {:ok, _membership} = + Accounts.create_user_org_membership(%{ + user_id: viewer.id, + organization_id: organization.id, + roles: [] + }) + + login = post(conn, "/api/v1/auth/login", %{"email" => viewer.email, "password" => @password}) + assert %{"data" => %{"token" => token}} = json_response(login, 200) + + bundle = get(api_conn(conn, token, organization.id), bundle_url(version.id, station.id)) + assert %{"data" => _} = json_response(bundle, 200) + + sync = + post(api_conn(conn, token, organization.id), sync_url(version.id, station.id), %{ + "pathways" => [] + }) + + assert %{"error" => %{"code" => "forbidden"}} = json_response(sync, 403) + + upload = + post_multipart( + api_conn(conn, token, organization.id), + photo_url(version.id, station.id), + %{}, + jpeg() + ) + + assert %{"error" => %{"code" => "forbidden"}} = json_response(upload, 403) + end + + test "a multi-organization user selects the intended membership for reads and writes", %{ + conn: conn + } do + user = user_fixture() + first_organization = organization_fixture() + second_organization = organization_fixture() + + {:ok, _} = + Accounts.create_user_org_membership(%{ + user_id: user.id, + organization_id: first_organization.id, + roles: [] + }) + + {:ok, _} = + Accounts.create_user_org_membership(%{ + user_id: user.id, + organization_id: second_organization.id, + roles: ["pathways_studio_editor"] + }) + + version = gtfs_version_fixture(second_organization.id) + %{station: station} = station_fixture(second_organization.id, version.id) + + login = post(conn, "/api/v1/auth/login", %{"email" => user.email, "password" => @password}) + assert %{"data" => %{"token" => token}} = json_response(login, 200) + + no_selection = get(api_conn(conn, token), "/api/v1/versions") + assert %{"error" => %{"code" => "organization_required"}} = json_response(no_selection, 403) + + selected_versions = get(api_conn(conn, token, second_organization.id), "/api/v1/versions") + assert %{"data" => versions} = json_response(selected_versions, 200) + assert Enum.any?(versions, &(&1["id"] == version.id)) + + sync = + post(api_conn(conn, token, second_organization.id), sync_url(version.id, station.id), %{ + "pathways" => [], + "journal_entries" => [entry_payload("station")] + }) + + assert %{"data" => %{"journal_synced_count" => 1}} = json_response(sync, 200) + end + + defp editor_station_fixture do + user = user_fixture() + organization = organization_fixture() + + {:ok, _membership} = + Accounts.create_user_org_membership(%{ + user_id: user.id, + organization_id: organization.id, + roles: ["pathways_studio_editor"] + }) + + version = gtfs_version_fixture(organization.id) + station_data = station_fixture(organization.id, version.id) + + %{user: user, organization: organization, version: version, station: station_data.station} + end + + defp station_fixture(organization_id, version_id) do + level = level_fixture(organization_id, version_id) + station = stop_fixture(organization_id, version_id, %{location_type: 1, parent_station: nil}) + + node = + stop_fixture(organization_id, version_id, %{ + parent_station: station.stop_id, + level_id: level.level_id + }) + + other_node = + stop_fixture(organization_id, version_id, %{ + parent_station: station.stop_id, + level_id: level.level_id + }) + + pathway_fixture(organization_id, version_id, node.stop_id, other_node.stop_id) + + {:ok, _stop_level} = + Gtfs.create_stop_level(%{ + organization_id: organization_id, + gtfs_version_id: version_id, + stop_id: station.id, + level_id: level.id + }) + + %{station: station} + end + + defp api_conn(conn, token, organization_id \\ nil) do + conn = + conn + |> put_req_header("accept", "application/json") + |> put_req_header("authorization", "Bearer #{token}") + + if organization_id, do: put_req_header(conn, "x-organization-id", organization_id), else: conn + end + + defp entry_payload(target_type, attrs \\ %{}) do + Map.merge( + %{ + "id" => Ecto.UUID.generate(), + "target_type" => target_type, + "body" => "Mobile journal entry", + "captured_at" => "2026-07-13T10:00:00Z" + }, + attrs + ) + end + + defp expected_entry(payload, author_id, extra) do + Map.merge( + %{ + "id" => payload["id"], + "target_type" => payload["target_type"], + "body" => payload["body"], + "author_id" => author_id, + "captured_at" => "2026-07-13T10:00:00.000000Z", + "closed_at" => nil, + "closed_by" => nil, + "photos" => [] + }, + extra + ) + end + + defp post_multipart(conn, url, metadata, bytes) do + boundary = "station-journal-#{System.unique_integer([:positive])}" + body = multipart_body(boundary, metadata, bytes) + + conn + |> put_req_header("content-type", "multipart/form-data; boundary=#{boundary}") + |> post(url, body) + end + + defp multipart_body(boundary, metadata, bytes) do + [ + "--", + boundary, + "\r\n", + "content-disposition: form-data; name=\"metadata\"\r\n\r\n", + Jason.encode!(metadata), + "\r\n", + "--", + boundary, + "\r\n", + "content-disposition: form-data; name=\"file\"; filename=\"capture.jpg\"\r\n", + "content-type: image/jpeg\r\n\r\n", + bytes, + "\r\n--", + boundary, + "--\r\n" + ] + |> IO.iodata_to_binary() + end + + defp bundle_url(version_id, station_id), + do: "/api/v1/versions/#{version_id}/stations/#{station_id}/bundle" + + defp sync_url(version_id, station_id), + do: "/api/v1/versions/#{version_id}/stations/#{station_id}/sync" + + defp photo_url(version_id, station_id), + do: "/api/v1/versions/#{version_id}/stations/#{station_id}/journal-photos" + + defp jpeg, do: <<0xFF, 0xD8, "journal", 0xFF, 0xD9>> + + defp journal_ids(bundle) do + (bundle["journal_entries"] ++ + Enum.flat_map(bundle["stops"], & &1["journal_entries"]) ++ + Enum.flat_map(bundle["pathways"], & &1["journal_entries"]) ++ + Enum.flat_map(bundle["levels"], & &1["journal_entries"])) + |> Enum.map(& &1["id"]) + end +end diff --git a/test/gtfs_planner_web/api/v1/sync_controller_test.exs b/test/gtfs_planner_web/api/v1/sync_controller_test.exs index 5ddd0e6f..bc2a800b 100644 --- a/test/gtfs_planner_web/api/v1/sync_controller_test.exs +++ b/test/gtfs_planner_web/api/v1/sync_controller_test.exs @@ -8,6 +8,7 @@ defmodule GtfsPlannerWeb.Api.V1.SyncControllerTest do alias GtfsPlanner.Accounts alias GtfsPlanner.Repo + alias GtfsPlanner.Gtfs.JournalEntry alias GtfsPlanner.Gtfs.Pathway # --------------------------------------------------------------------------- @@ -18,14 +19,14 @@ defmodule GtfsPlannerWeb.Api.V1.SyncControllerTest do user = user_fixture() org = organization_fixture() - {:ok, _membership} = + {:ok, membership} = Accounts.create_user_org_membership(%{ user_id: user.id, organization_id: org.id, roles: ["pathways_studio_editor"] }) - %{user: user, org: org} + %{user: user, org: org, membership: membership} end defp authed_conn(conn, user) do @@ -70,6 +71,57 @@ defmodule GtfsPlannerWeb.Api.V1.SyncControllerTest do describe "create/2" do setup [:setup_user_with_org] + test "returns 401 before organization or editor authorization without a session", %{ + conn: conn + } do + conn = post(conn, sync_url(Ecto.UUID.generate(), Ecto.UUID.generate()), %{"pathways" => []}) + + assert %{"error" => %{"code" => "unauthorized"}} = json_response(conn, 401) + end + + test "rejects a selected non-editor membership without mutating pathways", %{ + conn: conn, + user: user, + org: org, + membership: membership + } do + version = gtfs_version_fixture(org.id) + %{station: station, pathway: pathway} = build_station_with_pathway(org.id, version.id) + + {:ok, _membership} = + membership + |> Ecto.Changeset.change(%{roles: []}) + |> Repo.update() + + conn = + conn + |> authed_conn(user) + |> post(sync_url(version.id, station.id), %{ + "pathways" => [%{"id" => pathway.id, "traversal_time" => 45}] + }) + + assert %{"error" => %{"code" => "forbidden"}} = json_response(conn, 403) + assert Repo.get!(Pathway, pathway.id).traversal_time != 45 + end + + test "keeps protected reads available to an active non-editor membership", %{ + conn: conn, + user: user, + membership: membership + } do + {:ok, _membership} = + membership + |> Ecto.Changeset.change(%{roles: []}) + |> Repo.update() + + conn = + conn + |> authed_conn(user) + |> get("/api/v1/versions") + + assert %{"data" => _versions} = json_response(conn, 200) + end + test "syncs editable fields successfully (traversal_time, signposted_as, field_notes, field_completed_at)", %{conn: conn, user: user, org: org} do version = gtfs_version_fixture(org.id) @@ -105,6 +157,25 @@ defmodule GtfsPlannerWeb.Api.V1.SyncControllerTest do assert %DateTime{} = updated.field_completed_at end + test "applies duplicate pathway updates in request order", %{conn: conn, user: user, org: org} do + version = gtfs_version_fixture(org.id) + %{station: station, pathway: pathway} = build_station_with_pathway(org.id, version.id) + + conn = + conn + |> authed_conn(user) + |> post(sync_url(version.id, station.id), %{ + "pathways" => [ + %{"id" => pathway.id, "traversal_time" => 10}, + %{"id" => pathway.id, "traversal_time" => pathway.traversal_time} + ] + }) + + assert %{"data" => %{"synced_count" => 2} = data} = json_response(conn, 200) + refute Map.has_key?(data, "errors") + assert Repo.get!(Pathway, pathway.id).traversal_time == pathway.traversal_time + end + test "does not modify read-only fields (pathway_mode)", %{conn: conn, user: user, org: org} do version = gtfs_version_fixture(org.id) @@ -339,7 +410,12 @@ defmodule GtfsPlannerWeb.Api.V1.SyncControllerTest do |> authed_conn(user) |> post(sync_url(version.id, station.id), %{"other_key" => "value"}) - assert %{"error" => %{"code" => "bad_request"}} = json_response(conn, 400) + assert %{ + "error" => %{ + "code" => "bad_request", + "message" => "Request must include a 'pathways' array." + } + } = json_response(conn, 400) end test "partial failure: some pathways succeed, some fail", @@ -369,5 +445,180 @@ defmodule GtfsPlannerWeb.Api.V1.SyncControllerTest do updated = Repo.get!(Pathway, good_pathway.id) assert updated.traversal_time == 90 end + + test "returns 400 for malformed URL IDs and malformed top-level lists before writes", %{ + conn: conn, + user: user, + org: org + } do + version = gtfs_version_fixture(org.id) + %{station: station, pathway: pathway} = build_station_with_pathway(org.id, version.id) + + malformed_url_conn = + conn + |> authed_conn(user) + |> post(sync_url("not-a-uuid", station.id), %{ + "pathways" => [%{"id" => pathway.id, "traversal_time" => 77}] + }) + + assert %{"error" => %{"code" => "bad_request"}} = json_response(malformed_url_conn, 400) + assert Repo.get!(Pathway, pathway.id).traversal_time != 77 + + malformed_list_conn = + build_conn() + |> authed_conn(user) + |> post(sync_url(version.id, station.id), %{"pathways" => [], "journal_entries" => %{}}) + + assert %{ + "error" => %{ + "code" => "bad_request", + "message" => "Request must include a 'journal_entries' array when provided." + } + } = json_response(malformed_list_conn, 400) + end + + test "returns 404 for missing or cross-scope stations before processing items", %{ + conn: conn, + user: user, + org: org + } do + version = gtfs_version_fixture(org.id) + %{pathway: pathway} = build_station_with_pathway(org.id, version.id) + + conn = + conn + |> authed_conn(user) + |> post(sync_url(version.id, Ecto.UUID.generate()), %{ + "pathways" => [%{"id" => pathway.id, "traversal_time" => 77}] + }) + + assert %{"error" => %{"code" => "not_found"}} = json_response(conn, 404) + assert Repo.get!(Pathway, pathway.id).traversal_time != 77 + end + + test "accepts journal-only and mixed requests with independent counts and a server timestamp", + %{ + conn: conn, + user: user, + org: org + } do + version = gtfs_version_fixture(org.id) + %{station: station, pathway: pathway} = build_station_with_pathway(org.id, version.id) + + journal_id = Ecto.UUID.generate() + + conn = + conn + |> authed_conn(user) + |> post(sync_url(version.id, station.id), %{ + "pathways" => [%{"id" => pathway.id, "traversal_time" => 31}], + "journal_entries" => [ + %{ + "id" => journal_id, + "target_type" => "station", + "body" => "Entrance inspection", + "captured_at" => "2025-06-01T12:00:00Z", + "organization_id" => Ecto.UUID.generate(), + "author_id" => Ecto.UUID.generate(), + "lat" => 99.0, + "closed_at" => "2025-06-01T13:00:00Z" + } + ] + }) + + assert %{"data" => data} = json_response(conn, 200) + assert data["synced_count"] == 1 + assert data["journal_synced_count"] == 1 + assert %DateTime{} = DateTime.from_iso8601(data["synced_at"]) |> elem(1) + + entry = Repo.get!(JournalEntry, journal_id) + assert entry.organization_id == org.id + assert entry.author_id == user.id + assert is_nil(entry.lat) + assert is_nil(entry.closed_at) + end + + test "accepts 100 journal entries and rejects 101 before any write", %{ + conn: conn, + user: user, + org: org + } do + version = gtfs_version_fixture(org.id) + %{station: station} = build_station_with_pathway(org.id, version.id) + + entries = + for _ <- 1..100 do + %{ + "id" => Ecto.UUID.generate(), + "target_type" => "station", + "captured_at" => "2025-06-01T12:00:00Z" + } + end + + accepted_conn = + conn + |> authed_conn(user) + |> post(sync_url(version.id, station.id), %{ + "pathways" => [], + "journal_entries" => entries + }) + + assert %{"data" => %{"journal_synced_count" => 100}} = json_response(accepted_conn, 200) + + rejected_conn = + build_conn() + |> authed_conn(user) + |> post(sync_url(version.id, station.id), %{ + "pathways" => [], + "journal_entries" => entries ++ [hd(entries)] + }) + + assert %{ + "error" => %{ + "code" => "bad_request", + "message" => "Request may include at most 100 journal entries." + } + } = json_response(rejected_conn, 400) + + assert Repo.aggregate(JournalEntry, :count, :id) == 100 + end + + test "scopes pathways and keeps valid journal siblings when an invalid target is rejected", %{ + conn: conn, + user: user, + org: org + } do + version = gtfs_version_fixture(org.id) + %{station: station} = build_station_with_pathway(org.id, version.id) + %{pathway: outside_pathway} = build_station_with_pathway(org.id, version.id) + valid_journal_id = Ecto.UUID.generate() + + conn = + conn + |> authed_conn(user) + |> post(sync_url(version.id, station.id), %{ + "pathways" => [%{"id" => outside_pathway.id, "traversal_time" => 81}], + "journal_entries" => [ + %{ + "id" => valid_journal_id, + "target_type" => "station", + "captured_at" => "2025-06-01T12:00:00Z" + }, + %{ + "id" => Ecto.UUID.generate(), + "target_type" => "pathway", + "target_id" => outside_pathway.id, + "captured_at" => "2025-06-01T12:00:00Z" + } + ] + }) + + assert %{"data" => data} = json_response(conn, 200) + assert data["synced_count"] == 0 + assert data["journal_synced_count"] == 1 + assert Enum.map(data["errors"], & &1["code"]) == ["not_found", "invalid_target"] + assert %JournalEntry{id: ^valid_journal_id} = Repo.get(JournalEntry, valid_journal_id) + assert Repo.get!(Pathway, outside_pathway.id).traversal_time != 81 + end end end diff --git a/test/gtfs_planner_web/live/gtfs/station_diagram_live_test.exs b/test/gtfs_planner_web/live/gtfs/station_diagram_live_test.exs index 38f82ca9..eaab4d9a 100644 --- a/test/gtfs_planner_web/live/gtfs/station_diagram_live_test.exs +++ b/test/gtfs_planner_web/live/gtfs/station_diagram_live_test.exs @@ -1501,10 +1501,16 @@ defmodule GtfsPlannerWeb.Gtfs.StationDiagramLiveTest do }) pathway = - pathway_fixture(organization.id, gtfs_version.id, child_stop.stop_id, other_stop.stop_id, %{ - pathway_mode: 1, - is_bidirectional: true - }) + pathway_fixture( + organization.id, + gtfs_version.id, + child_stop.stop_id, + other_stop.stop_id, + %{ + pathway_mode: 1, + is_bidirectional: true + } + ) conn = log_in_user(conn, user, organization: organization) diff --git a/test/gtfs_planner_web/parsers/multipart_test.exs b/test/gtfs_planner_web/parsers/multipart_test.exs new file mode 100644 index 00000000..276a0e9c --- /dev/null +++ b/test/gtfs_planner_web/parsers/multipart_test.exs @@ -0,0 +1,119 @@ +defmodule GtfsPlannerWeb.Parsers.MultipartTest do + use ExUnit.Case, async: true + + import Plug.Conn + import Plug.Test + + alias GtfsPlannerWeb.Parsers.Multipart + + @boundary "station-journal-boundary" + @journal_path [ + "api", + "v1", + "versions", + "version-id", + "stations", + "station-id", + "journal-photos" + ] + + describe "parse/5" do + test "accepts exact journal-photo POST multipart bodies below the journal limit" do + opts = Multipart.init(default_length: 100, journal_length: 1_000) + + assert {:ok, %{"metadata" => metadata}, conn} = + parse_multipart( + "POST", + @journal_path, + "metadata", + String.duplicate("a", 101), + opts + ) + + assert metadata == String.duplicate("a", 101) + refute conn.halted + end + + test "returns a CORS-enabled JSON 413 for oversized exact journal-photo requests" do + opts = Multipart.init(default_length: 100, journal_length: 100) + + assert {:ok, %{}, conn} = + parse_multipart( + "POST", + @journal_path, + "metadata", + String.duplicate("a", 101), + opts, + origin: "http://localhost:51091" + ) + + assert conn.halted + assert conn.status == 413 + assert conn.resp_body == Jason.encode!(%{error: %{code: "payload_too_large"}}) + + assert get_resp_header(conn, "access-control-allow-origin") == [ + "http://localhost:51091" + ] + end + + test "uses the default limit for non-POST or non-exact journal-photo routes" do + opts = Multipart.init(default_length: 100, journal_length: 1_000) + + for {method, path_info} <- [ + {"PUT", @journal_path}, + {"POST", + ["api", "v1", "versions", "version-id", "stations", "station-id", "journal-photo"]}, + {"POST", @journal_path ++ ["extra"]}, + {"POST", ["api", "v1", "unrelated", "multipart"]} + ] do + assert {:error, :too_large, conn} = + parse_multipart(method, path_info, "metadata", String.duplicate("a", 101), opts) + + refute conn.halted + end + end + end + + test "keeps URL-encoded and JSON parser behavior outside the multipart parser" do + parsers = + Plug.Parsers.init( + parsers: [:urlencoded, Multipart, :json], + pass: ["*/*"], + json_decoder: Jason + ) + + urlencoded = + conn(:post, "/api/v1/example", "name=journal") + |> put_req_header("content-type", "application/x-www-form-urlencoded") + + urlencoded = Plug.Parsers.call(urlencoded, parsers) + assert urlencoded.body_params == %{"name" => "journal"} + + json = + conn(:post, "/api/v1/example", ~s({"name":"journal"})) + |> put_req_header("content-type", "application/json") + |> Plug.Parsers.call(parsers) + + assert json.body_params == %{"name" => "journal"} + end + + defp parse_multipart(method, path_info, name, value, opts, extra_headers \\ []) do + body = multipart_body(name, value) + + conn(method, "/" <> Enum.join(path_info, "/"), body) + |> Map.put(:path_info, path_info) + |> put_req_header("content-type", "multipart/form-data; boundary=#{@boundary}") + |> then(fn conn -> + Enum.reduce(extra_headers, conn, fn {key, header_value}, conn -> + put_req_header(conn, to_string(key), header_value) + end) + end) + |> Multipart.parse("multipart", "form-data", %{"boundary" => @boundary}, opts) + end + + defp multipart_body(name, value) do + "--#{@boundary}\r\n" <> + "Content-Disposition: form-data; name=\"#{name}\"\r\n\r\n" <> + value <> "\r\n--#{@boundary}--\r\n" + end +end diff --git a/test/gtfs_planner_web/plugs/assign_api_organization_test.exs b/test/gtfs_planner_web/plugs/assign_api_organization_test.exs index e4f22bca..b232dae6 100644 --- a/test/gtfs_planner_web/plugs/assign_api_organization_test.exs +++ b/test/gtfs_planner_web/plugs/assign_api_organization_test.exs @@ -16,7 +16,7 @@ defmodule GtfsPlannerWeb.Plugs.AssignApiOrganizationTest do test "assigns their org", %{conn: conn, user: user} do org = organization_fixture() - {:ok, _membership} = + {:ok, membership} = GtfsPlanner.Accounts.create_user_org_membership(%{ user_id: user.id, organization_id: org.id, @@ -29,6 +29,7 @@ defmodule GtfsPlannerWeb.Plugs.AssignApiOrganizationTest do refute conn.halted assert conn.assigns[:current_organization_id] == org.id + assert conn.assigns[:current_organization_membership].id == membership.id end end @@ -44,7 +45,7 @@ defmodule GtfsPlannerWeb.Plugs.AssignApiOrganizationTest do roles: ["pathways_studio_editor"] }) - {:ok, _} = + {:ok, membership2} = GtfsPlanner.Accounts.create_user_org_membership(%{ user_id: user.id, organization_id: org2.id, @@ -58,6 +59,7 @@ defmodule GtfsPlannerWeb.Plugs.AssignApiOrganizationTest do refute conn.halted assert conn.assigns[:current_organization_id] == org2.id + assert conn.assigns[:current_organization_membership].id == membership2.id end end @@ -66,14 +68,14 @@ defmodule GtfsPlannerWeb.Plugs.AssignApiOrganizationTest do org1 = organization_fixture() org2 = organization_fixture() - {:ok, _} = + {:ok, _membership1} = GtfsPlanner.Accounts.create_user_org_membership(%{ user_id: user.id, organization_id: org1.id, roles: ["pathways_studio_editor"] }) - {:ok, _} = + {:ok, _membership2} = GtfsPlanner.Accounts.create_user_org_membership(%{ user_id: user.id, organization_id: org2.id, @@ -98,7 +100,7 @@ defmodule GtfsPlannerWeb.Plugs.AssignApiOrganizationTest do org = organization_fixture() other_org = organization_fixture() - {:ok, _} = + {:ok, _membership} = GtfsPlanner.Accounts.create_user_org_membership(%{ user_id: user.id, organization_id: org.id, @@ -160,7 +162,7 @@ defmodule GtfsPlannerWeb.Plugs.AssignApiOrganizationTest do org1 = organization_fixture() org2 = organization_fixture() - {:ok, _} = + {:ok, membership1} = GtfsPlanner.Accounts.create_user_org_membership(%{ user_id: user.id, organization_id: org1.id, @@ -185,6 +187,7 @@ defmodule GtfsPlannerWeb.Plugs.AssignApiOrganizationTest do # Only one active membership, so it auto-assigns refute conn.halted assert conn.assigns[:current_organization_id] == org1.id + assert conn.assigns[:current_organization_membership].id == membership1.id end end end diff --git a/test/gtfs_planner_web/plugs/require_api_editor_test.exs b/test/gtfs_planner_web/plugs/require_api_editor_test.exs new file mode 100644 index 00000000..44add940 --- /dev/null +++ b/test/gtfs_planner_web/plugs/require_api_editor_test.exs @@ -0,0 +1,68 @@ +defmodule GtfsPlannerWeb.Plugs.RequireApiEditorTest do + use GtfsPlannerWeb.ConnCase, async: true + + alias GtfsPlannerWeb.Plugs.RequireApiEditor + alias GtfsPlanner.Accounts + + import GtfsPlanner.AccountsFixtures + import GtfsPlanner.OrganizationsFixtures + + defp membership_fixture(attrs) do + attrs = Map.new(attrs) + user = user_fixture() + organization = organization_fixture() + + {:ok, membership} = + Accounts.create_user_org_membership(%{ + user_id: user.id, + organization_id: organization.id, + roles: Map.get(attrs, :roles, []) + }) + + membership + end + + test "continues for an active selected editor membership", %{conn: conn} do + membership = membership_fixture(roles: ["pathways_studio_editor"]) + + conn = + conn + |> assign(:current_organization_membership, membership) + |> RequireApiEditor.call([]) + + refute conn.halted + end + + test "halts with the API forbidden envelope for a selected non-editor membership", %{conn: conn} do + membership = membership_fixture(roles: []) + + conn = + conn + |> assign(:current_organization_membership, membership) + |> RequireApiEditor.call([]) + + assert conn.halted + assert conn.status == 403 + assert Jason.decode!(conn.resp_body) == %{"error" => %{"code" => "forbidden"}} + end + + test "fails closed when the selected membership is absent or deactivated", %{conn: conn} do + missing = RequireApiEditor.call(conn, []) + + assert missing.halted + assert missing.status == 403 + + deactivated = %{ + membership_fixture(roles: ["pathways_studio_editor"]) + | deactivated_at: DateTime.utc_now() + } + + deactivated_conn = + conn + |> assign(:current_organization_membership, deactivated) + |> RequireApiEditor.call([]) + + assert deactivated_conn.halted + assert deactivated_conn.status == 403 + end +end diff --git a/test/gtfs_planner_web/plugs/uploads_plug_test.exs b/test/gtfs_planner_web/plugs/uploads_plug_test.exs index d2bcf265..b5e30705 100644 --- a/test/gtfs_planner_web/plugs/uploads_plug_test.exs +++ b/test/gtfs_planner_web/plugs/uploads_plug_test.exs @@ -1,41 +1,44 @@ defmodule GtfsPlannerWeb.UploadsPlugTest do - use ExUnit.Case, async: true + use ExUnit.Case, async: false import Plug.Test import Plug.Conn alias GtfsPlannerWeb.UploadsPlug - @uploads_path Path.join(System.tmp_dir!(), "uploads_plug_test_#{:rand.uniform(100_000)}") - setup_all do - # Configure uploads path for tests - Application.put_env(:gtfs_planner, :uploads_path, @uploads_path) + previous = Application.get_env(:gtfs_planner, :uploads_path) on_exit(fn -> - # Cleanup test directory - File.rm_rf!(@uploads_path) + if is_nil(previous), + do: Application.delete_env(:gtfs_planner, :uploads_path), + else: Application.put_env(:gtfs_planner, :uploads_path, previous) end) :ok end setup do - # Ensure clean state for each test - File.rm_rf!(@uploads_path) - File.mkdir_p!(@uploads_path) - :ok + uploads_path = + Path.join(System.tmp_dir!(), "uploads_plug_test_#{System.unique_integer([:positive])}") + + Application.put_env(:gtfs_planner, :uploads_path, uploads_path) + File.mkdir_p!(uploads_path) + + on_exit(fn -> File.rm_rf!(uploads_path) end) + + %{uploads_path: uploads_path} end describe "call/2" do - test "serves file when it exists at /uploads path" do + test "serves file when it exists at /uploads path", %{uploads_path: uploads_path} do # Create test file with organization isolation org_id = "123" stop_id = "TEST_STATION" filename = "floor_plan.png" file_content = "fake png content" - file_dir = Path.join([@uploads_path, "diagrams", org_id, stop_id]) + file_dir = Path.join([uploads_path, "diagrams", org_id, stop_id]) File.mkdir_p!(file_dir) file_path = Path.join(file_dir, filename) File.write!(file_path, file_content) @@ -50,12 +53,14 @@ defmodule GtfsPlannerWeb.UploadsPlugTest do assert conn.resp_body == file_content end - test "adds CORS header when served to an allowed (localhost) origin" do + test "adds CORS header when served to an allowed (localhost) origin", %{ + uploads_path: uploads_path + } do org_id = "123" stop_id = "TEST_STATION" filename = "floor_plan.png" - file_dir = Path.join([@uploads_path, "diagrams", org_id, stop_id]) + file_dir = Path.join([uploads_path, "diagrams", org_id, stop_id]) File.mkdir_p!(file_dir) File.write!(Path.join(file_dir, filename), "fake png content") @@ -86,12 +91,12 @@ defmodule GtfsPlannerWeb.UploadsPlugTest do ] end - test "omits CORS header for a disallowed origin" do + test "omits CORS header for a disallowed origin", %{uploads_path: uploads_path} do org_id = "123" stop_id = "TEST_STATION" filename = "floor_plan.png" - file_dir = Path.join([@uploads_path, "diagrams", org_id, stop_id]) + file_dir = Path.join([uploads_path, "diagrams", org_id, stop_id]) File.mkdir_p!(file_dir) File.write!(Path.join(file_dir, filename), "fake png content") @@ -123,14 +128,16 @@ defmodule GtfsPlannerWeb.UploadsPlugTest do assert conn.status == nil end - test "provides tenant isolation - different org cannot access same stop_id" do + test "provides tenant isolation - different org cannot access same stop_id", %{ + uploads_path: uploads_path + } do # Create file for org 1 org1_id = "1" stop_id = "SHARED_STATION" filename = "diagram.png" file_content = "org 1 content" - file_dir = Path.join([@uploads_path, "diagrams", org1_id, stop_id]) + file_dir = Path.join([uploads_path, "diagrams", org1_id, stop_id]) File.mkdir_p!(file_dir) File.write!(Path.join(file_dir, filename), file_content) @@ -154,9 +161,9 @@ defmodule GtfsPlannerWeb.UploadsPlugTest do assert conn2.status == nil end - test "handles nested path segments correctly" do + test "handles nested path segments correctly", %{uploads_path: uploads_path} do # Create deeply nested file - file_dir = Path.join([@uploads_path, "diagrams", "org", "stop", "subdir"]) + file_dir = Path.join([uploads_path, "diagrams", "org", "stop", "subdir"]) File.mkdir_p!(file_dir) File.write!(Path.join(file_dir, "file.txt"), "nested content") @@ -199,5 +206,42 @@ defmodule GtfsPlannerWeb.UploadsPlugTest do assert conn.status == 403 assert conn.resp_body == "Forbidden" end + + test "serves only valid field captures with deterministic public image headers", %{ + uploads_path: uploads_path + } do + organization_id = Ecto.UUID.generate() + photo_id = Ecto.UUID.generate() + file_dir = Path.join([uploads_path, "field-captures", organization_id, "STATION"]) + File.mkdir_p!(file_dir) + File.write!(Path.join(file_dir, "#{photo_id}.png"), <<137, 80, 78, 71>>) + + conn = + conn(:get, "/uploads/field-captures/#{organization_id}/STATION/#{photo_id}.png") + |> UploadsPlug.call([]) + + assert conn.status == 200 + assert get_resp_header(conn, "content-type") == ["image/png"] + assert get_resp_header(conn, "cache-control") == ["public, max-age=31536000, immutable"] + assert get_resp_header(conn, "x-content-type-options") == ["nosniff"] + end + + test "does not apply field-capture image headers to an invalid field-capture filename", %{ + uploads_path: uploads_path + } do + file_dir = Path.join([uploads_path, "field-captures", "organization", "STATION"]) + File.mkdir_p!(file_dir) + File.write!(Path.join(file_dir, "untrusted.png"), "not a field capture") + + conn = + conn(:get, "/uploads/field-captures/organization/STATION/untrusted.png") + |> UploadsPlug.call([]) + + assert conn.status == 200 + + refute "public, max-age=31536000, immutable" in get_resp_header(conn, "cache-control") + + assert get_resp_header(conn, "x-content-type-options") == [] + end end end