Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 161 additions & 1 deletion lib/gtfs_planner/gtfs.ex
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ defmodule GtfsPlanner.Gtfs do
alias GtfsPlanner.Gtfs.FeedInfo
alias GtfsPlanner.Gtfs.FloorplanTransform
alias GtfsPlanner.Gtfs.Frequency
alias GtfsPlanner.Gtfs.JournalEntry
alias GtfsPlanner.Gtfs.JournalPhoto
alias GtfsPlanner.Gtfs.Level
alias GtfsPlanner.Gtfs.Location
alias GtfsPlanner.Gtfs.Network
Expand Down Expand Up @@ -594,7 +596,15 @@ 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),
# Pins anchor on diagram coordinates like nodes; their lat/lon
# is optional enrichment re-imputed here whenever the level is
# (re)aligned, using the client-supplied image dims — never at
# sync time.
{:ok, active_derived_pins} <-
derive_pin_coords(updated_stop_level, image_w, image_h),
{:ok, _active_updated_pins} <-
persist_derived_pin_coords_in_tx(active_derived_pins) do
Logger.debug(fn ->
"[ALIGN_APPLY_DEBUG] active_stop_level_persisted " <>
inspect(%{
Expand Down Expand Up @@ -750,6 +760,74 @@ defmodule GtfsPlanner.Gtfs do
end
end

# Derives lat/lon enrichment for every `pin` journal entry anchored to this
# level, mirroring `derive_child_stop_coords/3` for nodes: the canonical anchor
# is the pin's diagram coordinate; lat/lon is computed via the same
# `FloorplanTransform.svg_to_lat_lon/4` using the level's alignment and the
# client-supplied image dims. A pin whose diagram point can't normalize is
# skipped (its lat/lon is left untouched). Returns `{:error, :alignment_missing}`
# when the level isn't aligned so the alignment write can't silently produce
# geo-less pins on an aligned level.
defp derive_pin_coords(%StopLevel{} = stop_level, image_w, image_h) do
with {:ok, alignment} <- extract_alignment(stop_level),
:ok <- validate_positive_image_dims(image_w, image_h) do
stop_level.id
|> list_pin_entries_for_level()
|> Enum.reduce_while({:ok, []}, fn entry, {:ok, acc} ->
case Coordinates.normalize_point(%{x: entry.diagram_x, y: entry.diagram_y}) do
nil ->
{:cont, {:ok, acc}}

%{x: x, y: y} ->
case FloorplanTransform.svg_to_lat_lon(alignment, image_w, image_h, %{x: x, y: y}) do
{:ok, {lat, lon}} ->
{:cont, {:ok, [%{id: entry.id, lat: lat, lon: lon} | acc]}}

{:error, reason} ->
{:halt, {:error, {:transform, reason}}}
end
end
end)
|> case do
{:ok, entries} -> {:ok, Enum.reverse(entries)}
{:error, _} = error -> error
end
end
end

defp list_pin_entries_for_level(stop_level_id) do
from(e in JournalEntry,
where: e.target_type == "pin" and e.stop_level_id == ^stop_level_id
)
|> Repo.all()
end

defp persist_derived_pin_coords_in_tx(derived) when is_list(derived) do
derived
|> Enum.reduce_while({:ok, []}, fn entry, {:ok, updated} ->
case update_derived_pin_coords(entry) do
{:ok, updated_entry} -> {:cont, {:ok, [updated_entry | updated]}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
|> case do
{:ok, updated} -> {:ok, Enum.reverse(updated)}
{:error, _} = error -> error
end
end

defp update_derived_pin_coords(%{id: id, lat: lat, lon: lon}) do
case Repo.get(JournalEntry, id) do
nil ->
{:error, :journal_entry_not_found}

%JournalEntry{} = entry ->
entry
|> Ecto.Changeset.change(lat: lat, lon: lon)
|> Repo.update()
end
end

@doc """
Infers floorplan alignment for `stop_level` from anchored child stops and
eligible cross-level elevator pathways.
Expand Down Expand Up @@ -2178,6 +2256,88 @@ defmodule GtfsPlanner.Gtfs do
|> Repo.all()
end

@doc """
All station-journal entries for a station (any target), oldest first. See the
companion app's `specs/api/station-journal.md`.
"""
def list_journal_entries_for_station(organization_id, gtfs_version_id, station_id) do
from(e in JournalEntry,
where:
e.organization_id == ^organization_id and
e.gtfs_version_id == ^gtfs_version_id and
e.station_id == ^station_id,
order_by: [asc: e.captured_at, asc: e.inserted_at]
)
|> Repo.all()
end

@doc """
Upsert a station-journal entry by its client-generated `id` (idempotent). On
conflict, the mutable fields are replaced (last-write-wins on `body` /
`closed_at` / `closed_by`); scoping and authorship are preserved from the
original insert.

A `pin` entry's canonical anchor is its diagram coordinate (`stop_level_id` +
`diagram_x/y`), exactly like a node's `diagram_coordinate`. Sync never sets
`lat`/`lon`: that geographic enrichment is imputed at level-alignment time (see
`save_and_apply_stop_level_alignment/4`) and is therefore omitted from the
`on_conflict` replace list so a later metadata re-sync can't reset an
alignment-imputed lat/lon back to nil.
"""
def upsert_journal_entry(attrs) do
%JournalEntry{}
|> JournalEntry.changeset(attrs)
|> Repo.insert(
on_conflict:
{:replace,
[
:body,
:closed_at,
:closed_by,
:target_type,
:target_id,
:stop_level_id,
:diagram_x,
:diagram_y,
:updated_at
]},
conflict_target: :id
)
end

@doc """
Upsert a journal photo by its client-generated `id` (idempotent on retry). On
conflict the mutable metadata is replaced; scoping and the owning entry are
preserved from the original insert. See the companion app's
`specs/api/station-journal.md`.
"""
def upsert_journal_photo(attrs) do
%JournalPhoto{}
|> JournalPhoto.changeset(attrs)
|> Repo.insert(
on_conflict:
{:replace, [:filename, :content_type, :byte_size, :width, :height, :updated_at]},
conflict_target: :id
)
end

@doc """
All journal photos whose owning entry belongs to the given (org, version,
station), oldest first. Used to nest photos under their entries in the bundle.
"""
def list_journal_photos_for_station(organization_id, gtfs_version_id, station_id) do
from(p in JournalPhoto,
join: e in JournalEntry,
on: e.id == p.journal_entry_id,
where:
e.organization_id == ^organization_id and
e.gtfs_version_id == ^gtfs_version_id and
e.station_id == ^station_id,
order_by: [asc: p.captured_at, asc: p.inserted_at]
)
|> Repo.all()
end

defp descendant_stop_ids_query(organization_id, gtfs_version_id, station_stop_id) do
direct_child_ids =
from(s in Stop,
Expand Down
140 changes: 140 additions & 0 deletions lib/gtfs_planner/gtfs/journal_entry.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
defmodule GtfsPlanner.Gtfs.JournalEntry do
@moduledoc """
A station-journal entry: a field-collected note and/or photos, optionally
anchored to a target within the station. See the companion app's
`specs/api/station-journal.md`.

The `id` is client-generated (stable across offline capture → sync) and is the
upsert key. `target_type` is polymorphic over `station` (target_id null),
`node`, `pathway`, and `pin` (an arbitrary point on a level: `stop_level_id` +
`diagram_x/y` as the canonical anchor — exactly like a node's diagram
coordinate. `lat/lon` is optional enrichment imputed at level-alignment time,
not at sync, and stays nil on unaligned levels).
"""
use Ecto.Schema
import Ecto.Changeset
import GtfsPlanner.ChangesetHelpers

@target_types ~w(station node pathway pin)

@type t :: %__MODULE__{
id: Ecto.UUID.t(),
organization_id: Ecto.UUID.t(),
gtfs_version_id: Ecto.UUID.t(),
station_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,
lat: float() | nil,
lon: float() | nil,
body: String.t() | nil,
author_id: Ecto.UUID.t(),
captured_at: DateTime.t(),
closed_at: DateTime.t() | nil,
closed_by: Ecto.UUID.t() | nil,
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}

@primary_key {:id, :binary_id, autogenerate: false}
@foreign_key_type :binary_id

schema "journal_entries" do
field :target_type, :string
field :target_id, :binary_id
field :stop_level_id, :binary_id
field :diagram_x, :float
field :diagram_y, :float
field :lat, :float
field :lon, :float
field :body, :string
field :author_id, :binary_id
field :captured_at, :utc_datetime_usec
field :closed_at, :utc_datetime_usec
field :closed_by, :binary_id
field :station_id, :binary_id

belongs_to :organization, GtfsPlanner.Organizations.Organization,
foreign_key: :organization_id

belongs_to :gtfs_version, GtfsPlanner.Versions.GtfsVersion

timestamps(type: :utc_datetime_usec)
end

def target_types, do: @target_types

def changeset(entry, attrs) do
entry
|> cast(attrs, [
:id,
:organization_id,
:gtfs_version_id,
:station_id,
:target_type,
:target_id,
:stop_level_id,
:diagram_x,
:diagram_y,
:lat,
:lon,
:body,
:author_id,
:captured_at,
:closed_at,
:closed_by
])
|> trim_string_fields()
|> validate_required([
:id,
:organization_id,
:gtfs_version_id,
:station_id,
:target_type,
:author_id,
:captured_at
])
|> validate_inclusion(:target_type, @target_types)
|> validate_target()
|> foreign_key_constraint(:organization_id)
|> foreign_key_constraint(:gtfs_version_id)
|> foreign_key_constraint(:station_id)
|> foreign_key_constraint(:stop_level_id)
end

# Target-shape rules by `target_type`:
# - node/pathway: `target_id` required (the node/pathway UUID).
# - station: `target_id` must be blank.
# - pin: no `target_id`; `stop_level_id` + `diagram_x/y` required
# (the level + diagram-canvas point is the canonical anchor).
# lat/lon are optional enrichment imputed at alignment time.
defp validate_target(changeset) do
case get_field(changeset, :target_type) do
type when type in ["node", "pathway"] ->
if get_field(changeset, :target_id),
do: changeset,
else: add_error(changeset, :target_id, "is required for node and pathway targets")

"station" ->
if get_field(changeset, :target_id),
do: add_error(changeset, :target_id, "must be blank for station targets"),
else: changeset

"pin" ->
changeset
|> validate_pin_target_id()
|> validate_required([:stop_level_id, :diagram_x, :diagram_y])

_ ->
changeset
end
end

defp validate_pin_target_id(changeset) do
if get_field(changeset, :target_id),
do: add_error(changeset, :target_id, "must be blank for pin targets"),
else: changeset
end
end
Loading