Native Scan-Event Logging → “Senast sedd” — Design

Created: Sun 14 Jun 2026 17:28:28 CEST Updated: Sun 14 Jun 2026 17:28:28 CEST - Initial design Document Version: 1.0 - Initial design Security Classification: Internal Technical Documentation Target Audience: iOS Developer, Backend Developer Author: Paul Wisén


Goal

Every scan in the native iOS app logs GPS position, time, and who scanned the object. That data is surfaced in a “Senast sedd” (Last seen) section on the iOS object view: relative time, a reverse-geocoded place name, and who scanned it.

This is, in effect, the native-path slice of “Phase 2” of the scan-landing work (Plings-Web/docs/specs/2026-06-14-scan-landing-page-design.md / docs/plans/2026-06-14-scan-landing-page-phase1.md). The browser/Gateway path keeps deferring geolocation to its own future phase — only the native path is wired here.

Scope

In scope

  • iOS: capture GPS at scan time, request location permission, send geoLocation in the scan mutation, show a “Senast sedd” section on the object view.
  • API: actually persist :ScanEvent (today’s log_scan_event is a stub), denormalize the latest scan onto object_instances, expose a new non-breaking lastSeen object field.
  • Postgres migration: 3 new columns on object_instances.

Out of scope

  • Browser/Gateway-path geolocation + GDPR consent (its own future phase).
  • Public /o/:oid landing-page location display (coarsened coords) — future.
  • Map / MapKit rendering. The iOS display is text only.
  • A scan-history / audit-trail UI (the Neo4j :ScanEvent history is written but only the latest is surfaced).
  • Contact-owner / lost-and-found flows.

Prerequisite / coordination

log_scan_event in Plings-API/app/scan_resolvers_fixed.py is currently a stub (# TODO: Insert into actual scan_events table — logs and returns a mock id). Real :ScanEvent persistence is Task 1 of the unmerged origin/task/scan-landing-phase1 branch. This design makes the persistence a superset of that task — it adds geolocation, scanning_user, and the last_scanned_at denormalization — and stays compatible with the landing-page plan so the two do not fork the data model. If that branch has already merged Task 1 by implementation time, extend its log_scan_event; otherwise implement it here and let the landing-page work build on it.

Architecture (Approach 1: Neo4j history + Postgres denormalized latest)

Every native scan persists a full :ScanEvent node in Neo4j (the documented source-of-truth audit trail, per docs/database/neo4j-core-schema.md) and writes the latest snapshot onto the object_instances row in Postgres. “Senast sedd” reads come from that single Postgres row, so the existing last_scanned_at read path is just extended — no extra Neo4j round-trip on every object open.

Native scan (ScannerScreen + LocationManager)
   │  resolveIdentifier(input{ scanMetadata{ timestamp, geoLocation{lat,long,accuracy,consent} } }), user JWT
   ▼
Plings-API resolve_identifier
   ├─ verify_identifier (Neo4j) → object_id, owner
   └─ log_scan_event
        ├─ Neo4j:  CREATE (:ScanEvent {timestamp, latitude, longitude, scanning_user,
        │                              owner_at_time_of_scan, identifier_type, object_id, ip_hash})
        │          CREATE (:ScanEvent)-[:SCANNED]->(:PlingsIdentifier)
        └─ Postgres: UPDATE object_instances SET last_scanned_at, last_scanned_latitude,
                                                  last_scanned_longitude, last_scanned_by
   ▼
Object open → getObjectDetailsComplete → lastSeen { at, latitude, longitude, scannedBy, scannedByName }
   ▼
iOS ObjectScreen: CLGeocoder(lat,long) on-device → "Senast sedd: <relativ tid> · nära <plats> · av <namn/du>"

Components

Plings-iOS

  1. LocationManager (new, App target — PlingsKit stays pure of CoreLocation): thin ObservableObject over CLLocationManager. Requests when-in-use authorization; exposes a cached latest CLLocation via currentLocation() with a freshness window so a scan grabs a recent fix without blocking.
  2. Onboarding permission request: once after first successful login, gated by @AppStorage("didRequestLocationPermission"), preceded by a short explainer sheet, then the system prompt. Hook point: HomeView first appearance. If denied, never re-nag — graceful degrade.
  3. project.yml info.properties: add NSLocationWhenInUseUsageDescription (Swedish), mirroring the existing NSCameraUsageDescription / NFCReaderUsageDescription. (Without it iOS hard-crashes on the location request.)
  4. ResolveIdentifierOperation.ScanMetadata (PlingsKit): add optional geoLocation { latitude: Double, longitude: Double, accuracy: Double, consent: Bool }.
  5. ScannerScreen.handle: read LocationManager.currentLocation(); if present attach geoLocation(consent: true) to the operation, else omit. The scan never waits or blocks on location.
  6. ObjectScreen: new “Senast sedd” Section:
    • relative time from lastSeen.at (“för 2 timmar sedan”),
    • reverse-geocoded place from lastSeen.latitude/longitude (on-device CLGeocoder, resolved async at display; show nothing for place while resolving / on failure),
    • who: “du” when lastSeen.scannedBy == current user id, else lastSeen.scannedByName, else “någon”.

Plings-API

  1. Rewrite log_scan_event (app/scan_resolvers_fixed.py) to accept the Neo4j session, the UserContext (for scanning_user), and the resolved owner. Persist the :ScanEvent node + SCANNED relationship. Wrap in try/except — failure is logged and swallowed so the scan redirect/response is never broken.
  2. Denormalize latest to Postgres: when there is a resolved object_id and an authenticated user, UPDATE object_instances set last_scanned_at, last_scanned_latitude, last_scanned_longitude, last_scanned_by. Uses the Postgres engine already present on info.context["postgres"].
  3. New non-breaking GraphQL field on the ObjectInstance type returned by getObjectDetailsComplete. The ObjectInstance type already has a scalar lastSeen: String read by the web frontend (COALESCE(last_scanned_at, created_at)), so we add a new, differently-named nested field — lastSeenInfo — and leave the scalar untouched:
    type LastSeen { at: String, latitude: Float, longitude: Float, scannedBy: ID, scannedByName: String }
    # added to type ObjectInstance:
    lastSeenInfo: LastSeen
    
    • scannedByName resolves from a Supabase profiles table if one exists; otherwise it returns null and iOS falls back to “du”/”någon”. (Verify the profiles table/columns at implementation; do not assume.)
    • scannedBy returns the raw user id so iOS can detect “self” → “du”.

Postgres migration

Verified against the live gjzcqcxoacxkrchrzaen (Plings) project: object_instances already has last_scanned_at timestamptz and last_scanned_by uuid (FK → auth.users.id). So the migration adds only two columns:

  • last_scanned_latitude double precision NULL
  • last_scanned_longitude double precision NULL

ID-format convention (fixed, to avoid ambiguity): Neo4j :ScanEvent.scanning_user stores the prefixed subject user:<id> (matching the documented schema example). Postgres last_scanned_by is a uuid and the GraphQL lastSeenInfo.scannedBy field returns the raw UserContext.id (UUID string, no prefix), so iOS compares lastSeenInfo.scannedBy == currentUserId directly for the “du” check.

scannedByName resolves from the confirmed public.profiles table (profiles.full_name joined on profiles.id = object_instances.last_scanned_by); null when no profile row.

The migration is NOT run without Paul’s explicit go-ahead at that step (per Plings-API/CLAUDE.local.md).

Data model (Neo4j :ScanEvent)

Matches the already-documented schema (docs/database/neo4j-core-schema.md):

Property Type Source
timestamp DateTime scanMetadata.timestamp (fallback: server now)
latitude Float scanMetadata.geoLocation.latitude (nullable)
longitude Float scanMetadata.geoLocation.longitude (nullable)
scanning_user String user:<UserContext.id> (null on Gateway path)
owner_at_time_of_scan String object’s owner org/user at scan time (nullable)
identifier_type String transport code (q/n/r/b)
object_id String resolved object id (nullable for unknown tags)
ip_hash String hashed IP (Gateway path; null/empty for native)

(:ScanEvent)-[:SCANNED]->(:PlingsIdentifier {instanceKey}).

Error handling / edge cases

  • Permission denied / no fix → scan logs time + user, no coords; “Senast sedd” shows time
    • who, omits the place line.
  • Geocoding fails / offline → fall back to time + who (no place line).
  • ScanEvent write fails → logged, scan still succeeds (response/redirect unaffected).
  • Browser/Gateway pathscanning_user null, no geo (unchanged behaviour).
  • Never-scanned object → existing COALESCE(last_scanned_at, created_at) behaviour preserved for the legacy scalar; the new lastSeen object is null until first scan.
  • No authenticated user on native (shouldn’t happen) → skip the Postgres denormalization (no last_scanned_by), still attempt the Neo4j event.

Testing

  • API (TDD, pytest + unittest.mock):
    • log_scan_event persists a :ScanEvent with geo + scanning_user (mock Neo4j session; assert Cypher contains ScanEvent / SCANNED and the lat/long/user params).
    • log_scan_event updates object_instances (mock PG engine) when object + user present; skips PG when not.
    • Failure path: a raising session/engine does not propagate (scan still returns).
    • New lastSeen resolver returns the block (mock PG row with coords + a profiles join / null name).
    • Regression: existing tests/test_resolve_identifier_auth.py still passes (it patches log_scan_event; tolerate the new signature).
  • iOS: PlingsKit swift testScanMetadata encodes geoLocation correctly and omits it when nil. LocationManager kept thin (device code, not unit-tested). App build: xcrun xcodebuild build -project Plings-iOS/Plings.xcodeproj -scheme Plings -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO.
  • Gateway: unaffected (native bypasses it) — no change.
  • quality-control-enforcer review is mandatory (scan routing + GraphQL resolver + DB schema change).
  • iOS “done” = Paul verifies on-device (green build/test/QC = ready for device test only).

Cross-repo merge order

  1. Plings-API first (persistence + new lastSeen field + migration). Target main.
  2. Plings-iOS second (capture + display). Target main.
  3. Plings-Web: unaffected — no change in this feature.

Documentation to update (ship with the code)

  • Plings-Web/docs/database/neo4j-core-schema.md — note :ScanEvent persistence is now implemented for the native path (timestamp, latitude, longitude, scanning_user, owner_at_time_of_scan); SCANNED relationship.
  • Plings-Web/docs/database/SCHEMA-VERIFICATION.md — the 3 new object_instances columns.
  • Plings-Web/docs/api/ — the new lastSeen object field on object-detail.
  • Plings-iOS roadmap (CLAUDE.md iOS section) — note native scan logging + “Senast sedd”.