Native Scan-Event Logging → “Senast sedd” — Design
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
geoLocationin the scan mutation, show a “Senast sedd” section on the object view. - API: actually persist
:ScanEvent(today’slog_scan_eventis a stub), denormalize the latest scan ontoobject_instances, expose a new non-breakinglastSeenobject field. - Postgres migration: 3 new columns on
object_instances.
Out of scope
- Browser/Gateway-path geolocation + GDPR consent (its own future phase).
- Public
/o/:oidlanding-page location display (coarsened coords) — future. - Map / MapKit rendering. The iOS display is text only.
- A scan-history / audit-trail UI (the Neo4j
:ScanEventhistory 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
LocationManager(new, App target — PlingsKit stays pure of CoreLocation): thinObservableObjectoverCLLocationManager. Requests when-in-use authorization; exposes a cached latestCLLocationviacurrentLocation()with a freshness window so a scan grabs a recent fix without blocking.- Onboarding permission request: once after first successful login, gated by
@AppStorage("didRequestLocationPermission"), preceded by a short explainer sheet, then the system prompt. Hook point:HomeViewfirst appearance. If denied, never re-nag — graceful degrade. project.ymlinfo.properties: addNSLocationWhenInUseUsageDescription(Swedish), mirroring the existingNSCameraUsageDescription/NFCReaderUsageDescription. (Without it iOS hard-crashes on the location request.)ResolveIdentifierOperation.ScanMetadata(PlingsKit): add optionalgeoLocation { latitude: Double, longitude: Double, accuracy: Double, consent: Bool }.ScannerScreen.handle: readLocationManager.currentLocation(); if present attachgeoLocation(consent: true)to the operation, else omit. The scan never waits or blocks on location.ObjectScreen: new “Senast sedd”Section:- relative time from
lastSeen.at(“för 2 timmar sedan”), - reverse-geocoded place from
lastSeen.latitude/longitude(on-deviceCLGeocoder, resolved async at display; show nothing for place while resolving / on failure), - who: “du” when
lastSeen.scannedBy== current user id, elselastSeen.scannedByName, else “någon”.
- relative time from
Plings-API
- Rewrite
log_scan_event(app/scan_resolvers_fixed.py) to accept the Neo4j session, theUserContext(forscanning_user), and the resolved owner. Persist the:ScanEventnode +SCANNEDrelationship. Wrap in try/except — failure is logged and swallowed so the scan redirect/response is never broken. - Denormalize latest to Postgres: when there is a resolved
object_idand an authenticated user,UPDATE object_instancessetlast_scanned_at,last_scanned_latitude,last_scanned_longitude,last_scanned_by. Uses the Postgres engine already present oninfo.context["postgres"]. - New non-breaking GraphQL field on the
ObjectInstancetype returned bygetObjectDetailsComplete. TheObjectInstancetype already has a scalarlastSeen: Stringread 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: LastSeenscannedByNameresolves from a Supabase profiles table if one exists; otherwise it returnsnulland iOS falls back to “du”/”någon”. (Verify the profiles table/columns at implementation; do not assume.)scannedByreturns 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 NULLlast_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 path →
scanning_usernull, no geo (unchanged behaviour). - Never-scanned object → existing
COALESCE(last_scanned_at, created_at)behaviour preserved for the legacy scalar; the newlastSeenobject 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_eventpersists a:ScanEventwith geo +scanning_user(mock Neo4j session; assert Cypher containsScanEvent/SCANNEDand the lat/long/user params).log_scan_eventupdatesobject_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
lastSeenresolver returns the block (mock PG row with coords + a profiles join / null name). - Regression: existing
tests/test_resolve_identifier_auth.pystill passes (it patcheslog_scan_event; tolerate the new signature).
- iOS: PlingsKit
swift test—ScanMetadataencodesgeoLocationcorrectly and omits it when nil.LocationManagerkept 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
- Plings-API first (persistence + new
lastSeenfield + migration). Targetmain. - Plings-iOS second (capture + display). Target
main. - Plings-Web: unaffected — no change in this feature.
Documentation to update (ship with the code)
Plings-Web/docs/database/neo4j-core-schema.md— note:ScanEventpersistence is now implemented for the native path (timestamp, latitude, longitude, scanning_user, owner_at_time_of_scan);SCANNEDrelationship.Plings-Web/docs/database/SCHEMA-VERIFICATION.md— the 3 newobject_instancescolumns.Plings-Web/docs/api/— the newlastSeenobject field on object-detail.- Plings-iOS roadmap (
CLAUDE.mdiOS section) — note native scan logging + “Senast sedd”.