Add a scoped station journal companion API - #655
Conversation
…at capture TransitOPS/gtfs-planner#655 makes the backend's journal-photo upload endpoint validate strictly from byte markers (JPEG FF D8 FF / PNG signature), reject any mismatch between declared content_type and detected bytes with 422, and treat a photo UUID's bytes as immutable once stored (retry-with-different- bytes 409s). Today the app trusts the picker/plugin's claimed content type at capture time, so a device that hands over e.g. HEIC bytes labeled image/jpeg would 422 forever — the photo stays dirty and retries every sync with no way to recover. - lib/utils/image_format.dart: byte-marker format detection (JPEG/PNG), sibling to the existing readImageDimensions header parser, plus a validatePhotoBytes() attach-time gate (25 MiB cap + format check). - journal_composer_route.dart: sniff bytes at capture/attach time (camera and gallery both go through _addPhoto) and set contentType from the detected format, ignoring whatever the picker/plugin claims. Unsupported bytes are rejected with "Unsupported image format — please use a JPEG or PNG." before ever being staged; oversized bytes are rejected with a size message before ever reaching local storage or the sync queue. - api_sync_repository.dart (_uploadDirtyPhotos): defense in depth — re-sniff bytes read from disk before every upload. Always send the detected content type (not the stored one) so a stale/drifted row can't trigger the server's mismatch 422. If the bytes aren't JPEG/PNG at all, skip the upload and call the new db.markJournalPhotoFailed() instead of leaving it dirty forever. - database.dart: markJournalPhotoFailed() sets uploadState to the existing `failed` code (2) and clears isDirty, so getDirtyJournalPhotos() stops re-queuing an upload that can never succeed. This reuses upload_state values already defined in the schema (tables.dart) for exactly this purpose — no migration needed. The image_picker plugin may already re-encode HEIC to JPEG on some iOS configurations, but this guard doesn't assume that — it's a hard backstop regardless of picker/plugin behavior across platforms and versions. Tests: detector unit tests (JPEG, PNG, HEIC-like bytes, garbage, truncated buffers, size cap edges), a widget test driving the real capture/attach path through a faked ImagePickerPlatform (contentType override + rejection + message), and sync-repository tests for the upload-time mismatch-override and permanently-failed paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HoGkr6FT6SKZCYDBkNqKBj
Upstream gtfs-planner (TransitOPS/gtfs-planner#655) is adding an editor-role gate to every write endpoint (POST .../sync, POST .../journal-photos): a member without pathways_studio_editor on their selected organization membership gets 403 forbidden. The login response carries no role information, so the app can't read this directly today. Adds readOnlyModeProvider with three detection paths: a future-proof direct read from the login response if it ever carries roles; a side-effect-free capability probe (an empty-payload sync POST) fired once real version/station ids are known, fully backward compatible since it fails open against today's prod backend (no gate, probe returns 200); and a reactive signal from any real write's 403 forbidden, covering mid-session revocation. A persistent, non- dismissible banner shows while read-only, and write-triggering surfaces (pathway form edits, Mark Complete/Reverse, the journal composer's Post action and entry points, the sync button) are guarded with an explanatory SnackBar instead of silently failing or piling up local edits that could never sync. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HoGkr6FT6SKZCYDBkNqKBj
|
We ran the pending verification item — the complete database-backed suite on Result: 2002 tests, 1 failure, 6 skipped. The two failures addressed in
Client-side compatibility: we verified the retained contract against the shipped companion parsers (bundle shapes, per-item error 🤖 Generated with Claude Code |
The station-journal backend merged to main via #655 (replacing #641, which this branch was stacked on). Same tables and wire contract, different domain shape — journal operations now go through GtfsPlanner.Gtfs.StationJournal with a trusted Scope struct: - load_journal builds a Scope from the LiveView's already-authorized org/version/station + current user and calls Gtfs.list_station_journal/1; photos arrive preloaded on each entry (the separate photo listing and group_by are gone). - Photo thumbnails use PhotoStorage.public_path/2 instead of a hand-rolled field-captures URL helper. - switch_to_level drops assign_selectable_reference_stop_levels/ sync_reference_overlay_state — main replaced the reference-overlay internals with the other-levels work (#646); the journal pipeline mirrors main's search_stop level switch. - Test fixtures insert JournalEntry rows directly (the 641-era upsert_journal_entry sync path no longer exists; the companion's write path is the scoped sync API, which these desktop read/close tests don't exercise). Full suite on this branch: 2012 tests, 0 failures, 6 skipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HoGkr6FT6SKZCYDBkNqKBj
Add a scoped station journal companion API
Summary
This branch implements the station journal backend from the current
mainbranch while retaining the companion-facing API shape introduced in PR #641. It adds journal entry synchronization, photo upload and public delivery, journal data in station bundles, and pin-coordinate refresh during station alignment.PR #641 is used here as the compatibility reference for endpoint paths, request envelopes, response nesting, and the normal sync/upload/bundle workflow. The implementation is otherwise independent and introduces more explicit tenant scope, authorization, retry, storage, and database boundaries.
API shape retained from PR #641
POST /api/v1/versions/:version_id/stations/:station_id/sync, requiredpathwayslist, optionaljournal_entrieslist, client-generated UUIDs, and HTTP 200 partial-success responses withsynced_count, conditionaljournal_synced_count,synced_at, and per-item errors.data.journal_entries; node and pathway entries remain nested under their corresponding bundle objects; pin entries remain underdata.levels[].journal_entries; levels expose nullablestop_level_id.stop_level_id,diagram_coordinate, and nullable derived latitude/longitude.POST /api/v1/versions/:version_id/stations/:station_id/journal-photosremains multipart withfileand JSONmetadata; successful creation and identical retry return HTTP 201 underdata.photo.The companion repository was not available while this branch was prepared, so the provider-side mobile workflow test uses PR #641's shapes as the executable compatibility baseline.
Observable incompatibilities and contract differences
These differences are called out for rollout and consumer review; they are not judgments about the earlier implementation.
pathways_studio_editoron the exact selected organization membership. Reads remain available to any active selected membership.not_found.invalid_target.journal_entriesis limited to 100 items.closed_at, andclosed_byare accepted as unknown compatibility fields but ignored. Closure remains response-only in this API slice.id_conflict; same-scope replay may update only mutable body/target fields."journal_entries": []produces"journal_synced_count": 0; omittingjournal_entriesomits the count.invalid_id,invalid_target,id_conflict, andvalidation_error; photo upload adds 409id_conflict, 413payload_too_large, and 500storage_error. Some new 403/409/413/422 responses containerror.codewithouterror.message.journal_entry_idin both upload and bundle photo objects.Content-Type,X-Content-Type-Options: nosniff, and one-year immutable public caching.captured_at,inserted_at, then UUID.Implementation inventory and differences
Persistence and domain boundaries
JournalEntry,JournalPhoto,StationJournal.Scope,StationJournal, andPhotoStoragemodules rather than placing journal persistence in the general GTFS context.Synchronization correctness
Photo storage and recovery
.jpgand.pngcandidates before adopting an orphan, preventing an alternate-extension file from being left reachable under the same UUID.Web composition
X-Organization-Id.JournalJSONserializer for upload and bundle photo representations./uploads/field-captures/<org>/<station>/<uuid>.jpg|pngpaths; other uploaded-file behavior and traversal checks remain in place.Pin alignment
Correctness, security, and performance notes
Correctness
Security
pathways_studio_editorbefore enabling this branch.Performance
Review findings and known follow-ups
The latest recorded branch review (
reviews/branch-2-review.md) has aneeds-fixverdict. Its matching fix record (reviews/branch-2-fix.md) resolves all actionable findings from that review, including response-envelope drift, retry metadata handling, duplicate pathway updates, linear error accumulation, orphan-extension checks, locked target validation, concurrent temp-file cleanup, parser tests, collision coverage, controller status coverage, and exact consumer assertions.Deferred advisory items remain visible for follow-up:
MatchError, including guaranteed descriptor and temp-file cleanup.There is no
blockers.md, and the prepared spec records no open implementation blocker.Verification
4db6c8fcorrects both and removes an unused optional-argument warning.mix compile --warnings-as-errors, dependency cleanup, formatting, and whitespace checks pass. Themix precommitalias still stops at the repository-wide Credo advisory backlog before tests; the journal-specific Credo warnings are cleared, and the full test gate was run independently.Deployment and review considerations
pathways_studio_editor; no automatic role migration is included.journal_entry_idin bundle photos, andjournal_synced_count: 0for an explicit empty list.:global, while PostgreSQL row locks remain authoritative for durable identity.Spec folder:
.specs/station-journal-api/Closes #656