Native Scan-Event Logging → “Senast sedd” Implementation Plan
Native Scan-Event Logging → “Senast sedd” Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Every native iOS scan logs GPS + time + who scanned into the :ScanEvent it already persists, denormalizes the latest scan onto object_instances, and surfaces it as a “Senast sedd” row on the iOS object view.
Architecture: Approach 1 from the spec — Neo4j :ScanEvent is the audit history; the latest snapshot is denormalized onto Postgres object_instances for cheap reads. The native app captures a location fix and sends it in the existing resolveIdentifier mutation’s scanMetadata; the API extends the already-merged log_scan_event and adds a Postgres update + a new lastSeenInfo GraphQL field; iOS reverse-geocodes coordinates on-device at display.
Tech Stack: Plings-API (Python 3.10, Ariadne GraphQL, Neo4j async driver, SQLAlchemy async + Postgres/Supabase, pytest + unittest.mock); Plings-iOS (SwiftUI app + PlingsKit Swift package, CoreLocation, XCTest).
Spec: Plings-Web/docs/specs/2026-06-14-native-scan-logging-last-seen-design.md
Critical preconditions (read before starting)
- Base every worktree off
origin/<prodbranch>aftergit fetch, NEVER off the local checkout. The localPlings-API/checkout is currently dirty (another session’s WIP staged inapp/scan_resolvers_fixed.pyplus untracked test files).origin/mainis clean and already contains the Phase-1:ScanEventpersistence this plan builds on (log_scan_event(scan_data, neo4j_session, object_id=None)persisting a:ScanEvent+SCANNED). Confirm withgit show origin/main:app/scan_resolvers_fixed.py | grep -n "async def log_scan_event"before editing. - Verified live schema (
gjzcqcxoacxkrchrzaen):object_instancesalready haslast_scanned_at timestamptzandlast_scanned_by uuid(FK →auth.users.id).public.profileshasid uuid(FK →auth.users.id) +full_name text. The migration adds onlylast_scanned_latitude/last_scanned_longitude. - DB rule (
Plings-API/CLAUDE.local.md): do NOT apply the migration without Paul’s explicit go-ahead. Task 1 writes the SQL file; applying it is a gated step. - Merge order: all Plings-API tasks (1–6) merge to
mainfirst; Plings-iOS tasks (7–12) merge tomainafter the API is live. Plings-Web is untouched.
File structure
Plings-API (worktree off origin/main)
- Create:
migrations/add_scan_geolocation_columns.sql— the 2 new columns. - Modify:
app/scan_resolvers_fixed.py— extendlog_scan_event(geo +scanning_user+owner_at_time_of_scan); addupdate_last_scanned; wire both intoresolve_identifier. - Modify:
app/graphql.py— addtype LastSeen+lastSeenInfo: LastSeenonObjectInstance. - Modify:
app/object_detail_resolvers.py— addbuild_last_seen_infohelper; extend thegetObjectDetailsCompleteSQL + return dict. - Create tests:
tests/test_scan_geo_user.py,tests/test_update_last_scanned.py,tests/test_last_seen_info.py.
Plings-iOS (worktree off origin/main)
- Modify:
PlingsKit/Sources/PlingsKit/API/Operations.swift—geoLocationonResolveIdentifierOperation;lastSeenInfoinGetObjectDetailsOperation.document. - Modify:
PlingsKit/Sources/PlingsKit/API/Models.swift—LastSeenInfoonObjectDetails. - Create test:
PlingsKit/Tests/PlingsKitTests/ScanGeoEncodingTests.swift. - Create:
App/Support/LocationManager.swift. - Modify:
App/project.yml—NSLocationWhenInUseUsageDescription. - Modify:
App/AppModel.swift— exposecurrentUserId; hold aLocationManager. - Modify:
App/Screens/HomeView.swift— one-time permission request. - Modify:
App/Screens/ScannerScreen.swift— attach the location fix to the scan. - Modify:
App/Screens/ObjectScreen.swift— “Senast sedd” section + on-device reverse geocode.
PART A — Plings-API (merge to main first)
Task 0: Create the API worktree
- Step 1: Fetch and branch off clean origin
cd /Users/paul/Documents/GitHub/Plings/Plings-API
git fetch origin
git worktree add ../Plings-worktrees/Plings-API/native-scan-logging -b task/native-scan-logging origin/main
cd ../Plings-worktrees/Plings-API/native-scan-logging
poetry install
git show origin/main:app/scan_resolvers_fixed.py | grep -n "async def log_scan_event" # confirm the 3-arg persistence base
Expected: log_scan_event(scan_data, neo4j_session, object_id=None) is present.
Task 1: Postgres migration file (apply is GATED)
Files:
-
Create:
migrations/add_scan_geolocation_columns.sql -
Step 1: Write the migration SQL
-- migrations/add_scan_geolocation_columns.sql
-- Adds the latest-scan geolocation snapshot to object_instances.
-- last_scanned_at and last_scanned_by (uuid, FK auth.users) already exist.
-- Feeds the "Senast sedd" view; precise coords are owner-visible only.
ALTER TABLE public.object_instances
ADD COLUMN IF NOT EXISTS last_scanned_latitude double precision,
ADD COLUMN IF NOT EXISTS last_scanned_longitude double precision;
- Step 2: Commit the file (do NOT apply yet)
git add migrations/add_scan_geolocation_columns.sql
git commit -m "feat(db): migration for object_instances scan geolocation columns"
- Step 3: Request go-ahead, then apply via Supabase MCP
Ask Paul to confirm before applying. On confirmation, apply with the Supabase MCP apply_migration tool (project gjzcqcxoacxkrchrzaen, name add_scan_geolocation_columns, the SQL above). Then verify:
list_tables(schemapublic) showslast_scanned_latitude/last_scanned_longitudeonobject_instances.
The remaining API tasks use mocked DBs and do not require the migration to be applied to pass tests. The columns must exist before the feature works end-to-end on-device.
Task 2: Extend log_scan_event with geo + scanning_user + owner
Add latitude/longitude, scanning_user (from the JWT UserContext), and owner_at_time_of_scan to the persisted :ScanEvent. The function must keep swallowing failures so a scan is never broken.
Files:
- Modify:
app/scan_resolvers_fixed.py -
Test:
tests/test_scan_geo_user.py - Step 1: Write the failing test
# Plings-API/tests/test_scan_geo_user.py
import asyncio
from unittest.mock import AsyncMock, MagicMock
from app import scan_resolvers_fixed as sri
class _UC:
id = "11111111-1111-1111-1111-111111111111"
org_id = "org-1"
def _session(returned_id="scan_abc"):
session = MagicMock()
result = MagicMock()
result.single = AsyncMock(return_value={"id": returned_id})
session.run = AsyncMock(return_value=result)
return session
def test_log_scan_event_persists_geo_and_user():
session = _session("scan_geo")
scan_data = {
"type": "q",
"identifier": "4kyQCd5tMDjJVWJH5h95gUcjq3qTX2cj5nwjVyqBRwLo",
"path": "4.2.3.3.6",
"scanMetadata": {
"timestamp": "2026-06-14T08:00:00Z",
"geoLocation": {"latitude": 65.5841, "longitude": 22.1547,
"accuracy": 8.0, "consent": True},
},
}
out = asyncio.run(sri.log_scan_event(
scan_data, session, object_id="obj-1",
user=_UC(), owner_at_time_of_scan="org:org-1"))
assert out == "scan_geo"
_, kwargs = session.run.call_args
assert kwargs["lat"] == 65.5841
assert kwargs["lng"] == 22.1547
assert kwargs["scanning_user"] == "user:11111111-1111-1111-1111-111111111111"
assert kwargs["owner"] == "org:org-1"
cypher = session.run.call_args[0][0]
assert "latitude" in cypher and "scanning_user" in cypher
def test_log_scan_event_omits_geo_when_absent():
session = _session("scan_nogeo")
scan_data = {"type": "n", "identifier": "abc", "path": "4.2",
"scanMetadata": {"timestamp": "2026-06-14T08:00:00Z"}}
out = asyncio.run(sri.log_scan_event(scan_data, session, object_id=None))
assert out == "scan_nogeo"
_, kwargs = session.run.call_args
assert kwargs["lat"] is None and kwargs["lng"] is None
assert kwargs["scanning_user"] is None and kwargs["owner"] is None
- Step 2: Run test to verify it fails
Run: poetry run pytest tests/test_scan_geo_user.py -v
Expected: FAIL — log_scan_event has no user/owner_at_time_of_scan params and no lat/lng/scanning_user/owner Cypher params.
- Step 3: Replace
log_scan_eventinapp/scan_resolvers_fixed.py
Replace the entire existing async def log_scan_event(...) definition with:
async def log_scan_event(
scan_data: Dict[str, Any],
neo4j_session: Neo4jSession,
object_id: Optional[str] = None,
user: Optional[Any] = None,
owner_at_time_of_scan: Optional[str] = None,
) -> str:
"""Persist a :ScanEvent and (:ScanEvent)-[:SCANNED]->(:PlingsIdentifier).
Stores timestamp, transport, hashed IP, resolved object id, and — for the
native path — geolocation (latitude/longitude), the scanning user, and the
owner at time of scan. Geolocation is null on the browser/Gateway path.
"""
meta = scan_data.get("scanMetadata", {}) or {}
geo = meta.get("geoLocation") or {}
now = datetime.utcnow()
scan_id = f"scan_{uuid.uuid4()}"
scanning_user = f"user:{user.id}" if user and getattr(user, "id", None) else None
cypher = """
OPTIONAL MATCH (tag:PlingsIdentifier {instanceKey: $ikey})
CREATE (e:ScanEvent {
id: $id,
timestamp: datetime($ts),
identifier_type: $transport,
ip_hash: $ip_hash,
object_id: $oid,
latitude: $lat,
longitude: $lng,
scanning_user: $scanning_user,
owner_at_time_of_scan: $owner
})
FOREACH (_ IN CASE WHEN tag IS NULL THEN [] ELSE [1] END |
CREATE (e)-[:SCANNED]->(tag))
RETURN e.id AS id
"""
params = {
"id": scan_id,
"ikey": scan_data.get("identifier"),
"ts": meta.get("timestamp") or (now.isoformat() + "Z"),
"transport": scan_data.get("type"),
"ip_hash": hash_ip_address(meta.get("ipAddress", "")),
"oid": object_id,
"lat": geo.get("latitude"),
"lng": geo.get("longitude"),
"scanning_user": scanning_user,
"owner": owner_at_time_of_scan,
}
try:
result = await neo4j_session.run(cypher, **params)
record = await result.single()
logger.info(f"Scan event persisted: {scan_id}")
return record["id"] if record else scan_id
except Exception as e: # never let logging break the scan redirect
logger.error(f"Failed to persist scan event: {e}")
return scan_id
- Step 4: Run test to verify it passes
Run: poetry run pytest tests/test_scan_geo_user.py -v
Expected: PASS (both cases).
- Step 5: Commit
git add app/scan_resolvers_fixed.py tests/test_scan_geo_user.py
git commit -m "feat(scan): persist geolocation, scanning_user and owner on :ScanEvent"
Task 3: update_last_scanned Postgres helper + wire into resolver
Denormalize the latest scan onto object_instances and return the owner org so the :ScanEvent can record owner_at_time_of_scan. Only runs when there is a resolved object and an authenticated user.
Files:
- Modify:
app/scan_resolvers_fixed.py -
Test:
tests/test_update_last_scanned.py - Step 1: Write the failing test
# Plings-API/tests/test_update_last_scanned.py
import asyncio
from unittest.mock import AsyncMock, MagicMock
from app import scan_resolvers_fixed as sri
def _engine(owner_row=("org-9",)):
conn = MagicMock()
result = MagicMock()
result.fetchone = MagicMock(return_value=owner_row)
conn.execute = AsyncMock(return_value=result)
cm = MagicMock()
cm.__aenter__ = AsyncMock(return_value=conn)
cm.__aexit__ = AsyncMock(return_value=False)
engine = MagicMock()
engine.begin = MagicMock(return_value=cm)
return engine, conn
def test_update_last_scanned_writes_and_returns_owner():
engine, conn = _engine(("org-9",))
geo = {"latitude": 1.0, "longitude": 2.0}
out = asyncio.run(sri.update_last_scanned(
engine, object_id="obj-1", user_id="user-1",
geo=geo, timestamp_iso="2026-06-14T08:00:00Z"))
assert out == "org:org-9"
# params are the 2nd positional arg: conn.execute(text(...), {...})
args, _ = conn.execute.call_args
params = args[1]
assert params["oid"] == "obj-1" and params["uid"] == "user-1"
assert params["lat"] == 1.0 and params["lng"] == 2.0
def test_update_last_scanned_swallows_errors():
engine = MagicMock()
cm = MagicMock(); conn = MagicMock()
conn.execute = AsyncMock(side_effect=Exception("db down"))
cm.__aenter__ = AsyncMock(return_value=conn); cm.__aexit__ = AsyncMock(return_value=False)
engine.begin = MagicMock(return_value=cm)
out = asyncio.run(sri.update_last_scanned(
engine, object_id="obj-1", user_id="user-1", geo={}, timestamp_iso="t"))
assert out is None
- Step 2: Run test to verify it fails
Run: poetry run pytest tests/test_update_last_scanned.py -v
Expected: FAIL — update_last_scanned does not exist.
- Step 3: Add
update_last_scanned(abovelog_scan_event) and thetextimport
At the top of app/scan_resolvers_fixed.py, with the other imports, add:
from sqlalchemy import text
Add this function immediately before async def log_scan_event:
async def update_last_scanned(
pg_engine,
object_id: str,
user_id: str,
geo: Dict[str, Any],
timestamp_iso: str,
) -> Optional[str]:
"""Denormalize the latest scan onto object_instances; return 'org:<owner>'.
Failure is swallowed (returns None) so a scan is never broken by the write.
"""
try:
async with pg_engine.begin() as conn:
result = await conn.execute(
text(
'''UPDATE object_instances
SET last_scanned_at = :ts,
last_scanned_by = :uid,
last_scanned_latitude = :lat,
last_scanned_longitude = :lng
WHERE id = :oid
RETURNING owner_organization_id'''
),
{"ts": timestamp_iso, "uid": user_id,
"lat": geo.get("latitude"), "lng": geo.get("longitude"),
"oid": object_id},
)
row = result.fetchone()
return f"org:{row[0]}" if row and row[0] else None
except Exception as e:
logger.error(f"Failed to update last_scanned for {object_id}: {e}")
return None
- Step 4: Wire it into
resolve_identifier
Find the existing scan-logging block (the scan_event_id = await log_scan_event(...) call inside async with neo4j_driver.session() as neo4j_session:). Replace it with:
# Native path: denormalize the latest scan onto Postgres and capture
# the owner so the :ScanEvent can record owner_at_time_of_scan.
user = info.context.get("user")
object_id = verification_result.get("object_id")
owner_at_time_of_scan = None
if object_id and user and getattr(user, "id", None):
pg_engine = info.context.get("postgres")
if pg_engine is not None:
owner_at_time_of_scan = await update_last_scanned(
pg_engine,
object_id=object_id,
user_id=user.id,
geo=(scan_metadata.get("geoLocation") or {}),
timestamp_iso=scan_metadata.get("timestamp")
or (datetime.utcnow().isoformat() + "Z"),
)
# Log scan event (persists :ScanEvent with geo + user + owner).
scan_event_id = await log_scan_event(
input,
neo4j_session,
object_id=object_id,
user=user,
owner_at_time_of_scan=owner_at_time_of_scan,
)
# Forward the scan id so the browser landing page can enrich the
# :ScanEvent with coarse geolocation in its own future phase.
if routing_decision.get('parameters') is not None:
routing_decision['parameters'].append({'key': 'sid', 'value': scan_event_id})
(scan_metadata is already assigned near the top of the try block; info.context.get("postgres") is the same engine used by object_detail_resolvers and public_resolvers.)
- Step 5: Run the new test + the existing auth/persistence tests (no regression)
Run: poetry run pytest tests/test_update_last_scanned.py tests/test_scan_geo_user.py tests/test_resolve_identifier_auth.py -v
Expected: PASS. (test_resolve_identifier_auth.py patches log_scan_event with an AsyncMock, tolerating the new keyword args, and its _ctx already supplies "postgres": MagicMock(). The _UC there has no real DB, but update_last_scanned only runs for a known object + user; in test_authenticated_user_resolves_known_tag the MagicMock postgres engine’s .begin() returns a MagicMock, whose __aenter__ is not awaitable — so update_last_scanned’s try raises and is swallowed, returning None. The assertions there only check routing/object/sid, so they still pass.)
- Step 6: Commit
git add app/scan_resolvers_fixed.py tests/test_update_last_scanned.py
git commit -m "feat(scan): denormalize latest scan (time/geo/user) onto object_instances"
Task 4: lastSeenInfo GraphQL field
Expose a new nested field on ObjectInstance (the scalar lastSeen is left untouched for the web). A pure helper builds the dict so it is unit-testable without DB mocking.
Files:
- Modify:
app/graphql.py - Modify:
app/object_detail_resolvers.py -
Test:
tests/test_last_seen_info.py - Step 1: Add SDL —
type LastSeen+ field onObjectInstance
In app/graphql.py, inside the type ObjectInstance { ... } block, add a line after lastSeen: String (line ~144):
lastSeenInfo: LastSeen
Add a new type near ObjectInstance (e.g. just after its closing }):
type LastSeen {
at: String
latitude: Float
longitude: Float
scannedBy: ID
scannedByName: String
}
- Step 2: Sanity-check the schema assembles
Run: poetry run python -c "import app.graphql"
Expected: no exception.
- Step 3: Write the failing test for the helper
# Plings-API/tests/test_last_seen_info.py
import datetime as dt
from app.object_detail_resolvers import build_last_seen_info
def test_build_last_seen_info_full():
row = {
"last_scanned_at": dt.datetime(2026, 6, 14, 8, 0, 0),
"last_scanned_latitude": 65.5841,
"last_scanned_longitude": 22.1547,
"last_scanned_by": "11111111-1111-1111-1111-111111111111",
"scanned_by_name": "Paul Wisén",
}
out = build_last_seen_info(row)
assert out == {
"at": "2026-06-14T08:00:00",
"latitude": 65.5841,
"longitude": 22.1547,
"scannedBy": "11111111-1111-1111-1111-111111111111",
"scannedByName": "Paul Wisén",
}
def test_build_last_seen_info_none_when_never_scanned():
assert build_last_seen_info({"last_scanned_at": None}) is None
def test_build_last_seen_info_handles_missing_geo_and_name():
row = {"last_scanned_at": dt.datetime(2026, 6, 14, 8, 0, 0),
"last_scanned_latitude": None, "last_scanned_longitude": None,
"last_scanned_by": None, "scanned_by_name": None}
out = build_last_seen_info(row)
assert out["at"] == "2026-06-14T08:00:00"
assert out["latitude"] is None and out["scannedByName"] is None
- Step 4: Run test to verify it fails
Run: poetry run pytest tests/test_last_seen_info.py -v
Expected: FAIL — build_last_seen_info does not exist.
- Step 5: Add the helper in
app/object_detail_resolvers.py
Add near the top of the module (after imports / other module helpers):
def build_last_seen_info(row) -> Optional[Dict[str, Any]]:
"""Build the lastSeenInfo GraphQL object from an object_instances row.
Returns None when the object has never been scanned (no last_scanned_at).
`row` is a mapping with last_scanned_* columns and an optional
scanned_by_name (profiles.full_name).
"""
at = row.get("last_scanned_at")
if not at:
return None
by = row.get("last_scanned_by")
return {
"at": at.isoformat() if hasattr(at, "isoformat") else str(at),
"latitude": row.get("last_scanned_latitude"),
"longitude": row.get("last_scanned_longitude"),
"scannedBy": str(by) if by else None,
"scannedByName": row.get("scanned_by_name"),
}
(Optional, Dict, Any are already imported in this module — confirm the from typing import ... line includes them; add any missing.)
- Step 6: Run test to verify it passes
Run: poetry run pytest tests/test_last_seen_info.py -v
Expected: PASS (all three).
- Step 7: Extend the
getObjectDetailsCompleteSQL + return dict
In app/object_detail_resolvers.py, in the main resolve_object_details_complete path, change the full_result SELECT (around line 115–126) to also pull the geo columns and the profile name. Replace the SELECT body with:
'''SELECT oi.id, oi.name, oi.description, oi.created_by, oi.owner_organization_id,
oi."last_scanned_at", oi."created_at", oi."updated_at",
oi."last_scanned_latitude", oi."last_scanned_longitude", oi."last_scanned_by",
COALESCE(oi."last_scanned_at", oi."created_at") AS "lastSeen",
o.name as organization_name, o.type as organization_type,
p.full_name AS scanned_by_name
FROM object_instances oi
LEFT JOIN organizations o ON oi.owner_organization_id = o.id
LEFT JOIN profiles p ON p.id = oi.last_scanned_by
WHERE oi.id = :object_id'''
Then in the final return { **obj_record, ... } dict of that function, add the key:
"lastSeenInfo": build_last_seen_info(obj_record),
(obj_record is the full_result.mappings().one() row; .get(...) works on SQLAlchemy RowMapping.)
- Step 8: Confirm schema still assembles and tests pass
Run: poetry run python -c "import app.graphql" && poetry run pytest tests/test_last_seen_info.py -v
Expected: import OK, tests PASS.
- Step 9: Commit
git add app/graphql.py app/object_detail_resolvers.py tests/test_last_seen_info.py
git commit -m "feat(api): expose lastSeenInfo (time/geo/scanner) on ObjectInstance"
Task 5: API docs + ROADMAP (ship with the code)
Files:
- Modify:
Plings-Web/docs/database/neo4j-core-schema.md - Modify:
Plings-Web/docs/database/SCHEMA-VERIFICATION.md - Modify:
Plings-Web/docs/api/(the resolve-identifier / object-detail doc) - Modify:
Plings-API/ROADMAP.md
Docs under
Plings-Web/docs/live in a different repo (targetsdev). Update them in a Plings-Web worktree (Task 11b below covers committing them ondev); thePlings-API/ROADMAP.mdchange is committed in the API worktree here.
- Step 1: Update
Plings-API/ROADMAP.md
Move/append a Done line referencing this work (native scan logging: geo + scanning_user on :ScanEvent, object_instances denormalization, lastSeenInfo) with date Sun 14 Jun 2026 and the PR ref (fill the PR number after creating it). Add any newly-found todos to Backlog (e.g. “browser-path geolocation + GDPR consent”, “scan-history view”).
- Step 2: Note the doc edits for the Plings-Web side
In neo4j-core-schema.md (:ScanEvent section) note latitude, longitude, scanning_user, owner_at_time_of_scan are now persisted on the native path. In SCHEMA-VERIFICATION.md add object_instances.last_scanned_latitude/longitude. In the API doc, document the new lastSeenInfo field. Add an **Updated**: timestamp line to each (date "+%a %d %b %Y %H:%M:%S %Z"). These are committed on dev in a Plings-Web worktree.
- Step 3: Commit the API ROADMAP
git add ROADMAP.md
git commit -m "docs(roadmap): native scan logging + lastSeenInfo done"
Task 6: API quality gate + ship
- Step 1: Full API test run
Run: poetry run pytest -q
Expected: green.
- Step 2: quality-control-enforcer review
Dispatch the quality-control-enforcer agent over the diff (mandatory: scan routing + GraphQL resolver + DB schema). Address findings.
- Step 3: PR + merge + fast-forward local checkout
git push -u origin task/native-scan-logging
gh pr create --base main --title "feat(scan): native scan logging (geo/time/user) + lastSeenInfo" --body "Extends :ScanEvent with geo+scanning_user+owner, denormalizes latest scan onto object_instances, adds lastSeenInfo. Merge BEFORE Plings-iOS. Spec: docs/specs/2026-06-14-native-scan-logging-last-seen-design.md"
gh pr merge --merge
# FF the clean local main checkout the user builds/deploys from:
git -C /Users/paul/Documents/GitHub/Plings/Plings-API fetch origin
git -C /Users/paul/Documents/GitHub/Plings/Plings-API merge --ff-only origin/main # only if that checkout is clean
If the local
Plings-API/checkout is still dirty (another session’s WIP), do not force the FF — report that it needs FF once clean.
PART B — Plings-iOS (merge to main AFTER the API is live)
Task 7: PlingsKit — geoLocation in the scan op + lastSeenInfo model
Files:
- Modify:
PlingsKit/Sources/PlingsKit/API/Operations.swift - Modify:
PlingsKit/Sources/PlingsKit/API/Models.swift -
Test:
PlingsKit/Tests/PlingsKitTests/ScanGeoEncodingTests.swift - Step 0: Create the iOS worktree (off clean origin)
cd /Users/paul/Documents/GitHub/Plings/Plings-iOS
git fetch origin
git worktree add ../Plings-worktrees/Plings-iOS/native-scan-logging -b task/native-scan-logging origin/main
cd ../Plings-worktrees/Plings-iOS/native-scan-logging
(cd PlingsKit && swift build)
xcodegen generate # so the .xcodeproj references current files in this worktree
- Step 1: Write the failing encoding test
// PlingsKit/Tests/PlingsKitTests/ScanGeoEncodingTests.swift
import XCTest
@testable import PlingsKit
final class ScanGeoEncodingTests: XCTestCase {
private func encode<T: Encodable>(_ v: T) throws -> [String: Any] {
let data = try JSONEncoder().encode(v)
return try JSONSerialization.jsonObject(with: data) as! [String: Any]
}
func testGeoLocationEncodedWhenProvided() throws {
let scan = ScannedIdentifier(rawScan: "https://s.plings.io/?t=q&i=ABC&p=4.2.3")!
let op = ResolveIdentifierOperation(
scan: scan, timestamp: "2026-06-14T08:00:00Z",
latitude: 65.5841, longitude: 22.1547, accuracy: 8.0)
let json = try encode(op.variables)
let input = json["input"] as! [String: Any]
let meta = input["scanMetadata"] as! [String: Any]
let geo = meta["geoLocation"] as! [String: Any]
XCTAssertEqual(geo["latitude"] as? Double, 65.5841)
XCTAssertEqual(geo["longitude"] as? Double, 22.1547)
XCTAssertEqual(geo["consent"] as? Bool, true)
}
func testGeoLocationOmittedWhenNil() throws {
let scan = ScannedIdentifier(rawScan: "https://s.plings.io/?t=q&i=ABC&p=4.2.3")!
let op = ResolveIdentifierOperation(scan: scan, timestamp: "2026-06-14T08:00:00Z")
let json = try encode(op.variables)
let meta = (json["input"] as! [String: Any])["scanMetadata"] as! [String: Any]
XCTAssertNil(meta["geoLocation"])
}
}
(If the rawScan: URL form differs from what ScannedIdentifier accepts, mirror the format used in the existing ScannedIdentifierTests.swift.)
- Step 2: Run test to verify it fails
Run: cd PlingsKit && swift test --filter ScanGeoEncodingTests
Expected: FAIL — ResolveIdentifierOperation.init has no latitude: etc., ScanMetadata has no geoLocation.
- Step 3: Add
geoLocationtoResolveIdentifierOperation
In Operations.swift, replace the ScanMetadata struct and the init of ResolveIdentifierOperation with:
public struct GeoLocation: Encodable, Sendable {
public let latitude: Double
public let longitude: Double
public let accuracy: Double?
public let consent: Bool
}
public struct ScanMetadata: Encodable, Sendable {
public let timestamp: String
public let geoLocation: GeoLocation?
}
Replace the init(scan:timestamp:):
public init(scan: ScannedIdentifier, timestamp: String,
latitude: Double? = nil, longitude: Double? = nil, accuracy: Double? = nil) {
let geo: GeoLocation?
if let latitude, let longitude {
geo = GeoLocation(latitude: latitude, longitude: longitude,
accuracy: accuracy, consent: true)
} else {
geo = nil
}
variables = Variables(input: Input(
type: scan.type,
identifier: scan.identifier,
path: scan.path,
classPointer: scan.classPointer,
scanMetadata: ScanMetadata(timestamp: timestamp, geoLocation: geo)))
}
JSONEncoder omits a nil optional key by default, so geoLocation is absent when no fix — matching the API’s optional input.
- Step 4: Add
lastSeenInfoto the detail query + model
In Operations.swift, in GetObjectDetailsOperation.document, add lastSeenInfo { at latitude longitude scannedBy scannedByName } inside the getObjectDetailsComplete(id: $id) { ... } selection (e.g. after tags).
In Models.swift, add to struct ObjectDetails:
public let lastSeenInfo: LastSeenInfo?
and a new type:
public struct LastSeenInfo: Decodable, Sendable {
public let at: String?
public let latitude: Double?
public let longitude: Double?
public let scannedBy: String?
public let scannedByName: String?
}
ObjectDetailsisDecodable; an added optional decodes tonilwhen absent, so older API responses (pre-deploy) still decode. The API ships first (Part A), so by iOS merge time the field exists.
- Step 5: Run tests
Run: cd PlingsKit && swift test --filter ScanGeoEncodingTests
Expected: PASS. Then full package: swift test — green.
- Step 6: Commit
git add PlingsKit/Sources/PlingsKit/API/Operations.swift PlingsKit/Sources/PlingsKit/API/Models.swift PlingsKit/Tests/PlingsKitTests/ScanGeoEncodingTests.swift
git commit -m "feat(kit): geoLocation on scan op + lastSeenInfo on ObjectDetails"
Task 8: LocationManager + Info.plist usage string
Files:
- Create:
App/Support/LocationManager.swift -
Modify:
App/project.yml - Step 1: Create
LocationManager
// App/Support/LocationManager.swift
import CoreLocation
/// Thin CoreLocation wrapper. Requests when-in-use authorization and keeps the
/// latest fix so a scan can attach a recent location without waiting. Device
/// code — kept deliberately minimal (no unit tests; verified via build + on-device).
@MainActor
final class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
@Published private(set) var latest: CLLocation?
override init() {
super.init()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
}
/// Authorization status without prompting.
var status: CLAuthorizationStatus { manager.authorizationStatus }
/// Show the system permission prompt (no-op if already decided).
func requestPermission() { manager.requestWhenInUseAuthorization() }
/// Best recent fix, or nil if unavailable / not permitted. Triggers a refresh
/// for next time. Never blocks.
func currentLocation() -> CLLocation? {
if status == .authorizedWhenInUse || status == .authorizedAlways {
manager.requestLocation()
}
// Only return a reasonably fresh fix.
if let loc = latest, loc.timestamp.timeIntervalSinceNow > -120 { return loc }
return latest
}
nonisolated func locationManager(_ m: CLLocationManager, didUpdateLocations locs: [CLLocation]) {
guard let loc = locs.last else { return }
Task { @MainActor in self.latest = loc }
}
nonisolated func locationManager(_ m: CLLocationManager, didFailWithError error: Error) { /* ignore; graceful */ }
nonisolated func locationManagerDidChangeAuthorization(_ m: CLLocationManager) {
Task { @MainActor in if m.authorizationStatus == .authorizedWhenInUse { m.requestLocation() } }
}
}
- Step 2: Add the usage string in
project.yml
In App/project.yml under info.properties, add alongside the camera/NFC strings:
NSLocationWhenInUseUsageDescription: "Platsen används för att registrera var en sak senast skannades, så att den kan visas under \"Senast sedd\"."
- Step 3: Regenerate the project and build
Run:
xcodegen generate
xcrun xcodebuild build -project Plings.xcodeproj -scheme Plings -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO
Expected: build succeeds (new file compiles, Info.plist has the key).
- Step 4: Commit
git add App/Support/LocationManager.swift App/project.yml
git commit -m "feat(ios): LocationManager + location usage description"
Task 9: Expose currentUserId, hold the LocationManager, request permission at onboarding
Files:
- Modify:
App/AppModel.swift -
Modify:
App/Screens/HomeView.swift - Step 1: Add
currentUserId+locationtoAppModel
In App/AppModel.swift, add stored/published properties near organizationId:
@Published var currentUserId: String?
let location = LocationManager()
In observeAuth(), set the id from the session inside the for await loop (the loop variable already binds session):
for await (_, session) in supabase.auth.authStateChanges {
isAuthenticated = session != nil
currentUserId = session?.user.id.uuidString.lowercased()
(Place the currentUserId assignment right after isAuthenticated = ...; keep the existing if isAuthenticated { ... } else { ... } block below.) In clearUserState() add currentUserId = nil.
- Step 2: Request location permission once after login (HomeView)
In App/Screens/HomeView.swift, add the flag and a .task (or .onAppear) on the root view:
@AppStorage("didRequestLocationPermission") private var didRequestLocation = false
.task {
if !didRequestLocation {
didRequestLocation = true
model.location.requestPermission()
}
}
(Use the existing @EnvironmentObject var model: AppModel in HomeView; if it is not already present, add it. If HomeView already has a .task, add these lines inside it.)
- Step 3: Build
Run: xcrun xcodebuild build -project Plings.xcodeproj -scheme Plings -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO
Expected: succeeds.
- Step 4: Commit
git add App/AppModel.swift App/Screens/HomeView.swift
git commit -m "feat(ios): expose currentUserId and request location permission at onboarding"
Task 10: Attach the location fix to the scan
Files:
-
Modify:
App/Screens/ScannerScreen.swift -
Step 1: Pass coordinates into the resolve operation
In ScannerScreen.handle(rawScan:), inside the Task { ... } where the timestamp is built, read the current fix and pass it to the operation. Replace the two lines that build timestamp and call ResolveIdentifierOperation(scan:timestamp:) with:
let timestamp = ISO8601DateFormatter().string(from: Date())
let fix = model.location.currentLocation()
let result = try await model.api.execute(
ResolveIdentifierOperation(
scan: scan, timestamp: timestamp,
latitude: fix?.coordinate.latitude,
longitude: fix?.coordinate.longitude,
accuracy: fix?.horizontalAccuracy))
(No waiting: currentLocation() returns the cached fix or nil; a denied/unavailable location simply sends no geoLocation.)
- Step 2: Build
Run: xcrun xcodebuild build -project Plings.xcodeproj -scheme Plings -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO
Expected: succeeds.
- Step 3: Commit
git add App/Screens/ScannerScreen.swift
git commit -m "feat(ios): attach GPS fix to scan resolveIdentifier"
Task 11: “Senast sedd” section in ObjectScreen
Files:
-
Modify:
App/Screens/ObjectScreen.swift -
Step 1: Add CoreLocation import + state
At the top of ObjectScreen.swift add import CoreLocation. Add state properties with the other @States:
@State private var lastSeenPlace: String?
- Step 2: Add the section
Insert a new Section immediately after the first Section { ... } closes (after line ~92, before Section("Åtgärder")):
if let ls = details?.lastSeenInfo, let at = ls.at {
Section("Senast sedd") {
Label(relativeTime(at), systemImage: "clock")
.font(.subheadline)
if let place = lastSeenPlace {
Label(place, systemImage: "mappin.and.ellipse").font(.subheadline)
}
if let who = scannedByLabel(ls) {
Label("Skannad av \(who)", systemImage: "person")
.font(.subheadline).foregroundStyle(.secondary)
}
}
}
- Step 3: Add the helpers + reverse-geocode task
Add these methods inside ObjectScreen (near load()):
private func relativeTime(_ iso: String) -> String {
let parsers: [ISO8601DateFormatter] = {
let a = ISO8601DateFormatter()
let b = ISO8601DateFormatter(); b.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return [a, b]
}()
let date = parsers.compactMap { $0.date(from: iso) }.first
guard let date else { return iso }
let f = RelativeDateTimeFormatter(); f.locale = Locale(identifier: "sv_SE")
return f.localizedString(for: date, relativeTo: Date())
}
private func scannedByLabel(_ ls: LastSeenInfo) -> String? {
if let by = ls.scannedBy, let me = model.currentUserId,
by.lowercased() == me.lowercased() { return "dig" }
return ls.scannedByName
}
Add a reverse-geocode .task to the List (alongside the existing .task at line ~189). Use .task(id:) keyed on the coordinate so it re-runs when details load:
.task(id: details?.lastSeenInfo?.latitude) {
guard let ls = details?.lastSeenInfo,
let lat = ls.latitude, let lng = ls.longitude else { lastSeenPlace = nil; return }
let placemarks = try? await CLGeocoder()
.reverseGeocodeLocation(CLLocation(latitude: lat, longitude: lng))
if let p = placemarks?.first {
lastSeenPlace = [p.thoroughfare, p.locality].compactMap { $0 }.joined(separator: ", ")
}
}
(If joining yields an empty string, leave lastSeenPlace nil so the place row is hidden — add let s = [...].joined(...); lastSeenPlace = s.isEmpty ? nil : s if you prefer to be explicit.)
- Step 4: Build
Run: xcrun xcodebuild build -project Plings.xcodeproj -scheme Plings -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO
Expected: succeeds.
- Step 5: Commit
git add App/Screens/ObjectScreen.swift
git commit -m "feat(ios): Senast sedd section (time/place/who) on object view"
Task 12: iOS quality gate, docs, ship
- Step 1: Full PlingsKit tests + app build
Run: cd PlingsKit && swift test then the xcodebuild build command above.
Expected: both green.
- Step 2: Update the iOS ROADMAP
In Plings-iOS/ROADMAP.md, move/append a Done line for native scan logging + “Senast sedd” (PR ref + date Sun 14 Jun 2026). Add any new todos to Backlog (e.g. “scan-history list”, “show place while geocoding via cached area”). Commit:
git add ROADMAP.md
git commit -m "docs(roadmap): native scan logging + Senast sedd done"
-
Step 3: quality-control-enforcer review over the iOS diff. Address findings.
-
Step 4: PR + merge (AFTER the API PR is merged & live) + FF + xcodegen
git push -u origin task/native-scan-logging
gh pr create --base main --title "feat(ios): native scan logging (GPS/time/who) + Senast sedd" --body "Captures GPS at scan, sends geoLocation; shows Senast sedd on the object view. Requires the Plings-API lastSeenInfo/geo PR to be live first."
gh pr merge --merge
# FF the local checkout the user builds from, then regenerate the project (files were ADDED):
git -C /Users/paul/Documents/GitHub/Plings/Plings-iOS fetch origin
git -C /Users/paul/Documents/GitHub/Plings/Plings-iOS merge --ff-only origin/main # clean checkout only
cd /Users/paul/Documents/GitHub/Plings/Plings-iOS && xcodegen generate
LocationManager.swiftand the test file are new files — perCLAUDE.md, the user’s local checkout needsxcodegen generateafter the FF or ⌘R fails withCannot find 'LocationManager' in scope.
- Step 5: Reap worktrees
For each merged worktree: git worktree remove <path> + git branch -D task/native-scan-logging + git worktree prune + git push origin --delete task/native-scan-logging.
- Step 6: Hand off for on-device test
iOS work is “ready for device test” once green + QC-passed — not “done” until Paul verifies on-device (scan an object, reopen it, confirm “Senast sedd” shows time + place + who).
Definition of done
- Plings-API:
poetry run pytest -qgreen; migration applied (with Paul’s go-ahead); QC passed; merged tomainand live. - Plings-iOS:
cd PlingsKit && swift testgreen; app builds; QC passed; merged tomainafter the API; local checkout FF’d +xcodegen generaterun. - Docs updated (neo4j-core-schema, SCHEMA-VERIFICATION, API doc on
dev; both repos’ROADMAP.md). - Paul confirms “Senast sedd” on-device.
- FINAL: quality-control-enforcer validated both diffs (mandatory: scan routing, GraphQL resolver, DB schema).