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> after git fetch, NEVER off the local checkout. The local Plings-API/ checkout is currently dirty (another session’s WIP staged in app/scan_resolvers_fixed.py plus untracked test files). origin/main is clean and already contains the Phase-1 :ScanEvent persistence this plan builds on (log_scan_event(scan_data, neo4j_session, object_id=None) persisting a :ScanEvent + SCANNED). Confirm with git show origin/main:app/scan_resolvers_fixed.py | grep -n "async def log_scan_event" before editing.
  • Verified live schema (gjzcqcxoacxkrchrzaen): object_instances already has last_scanned_at timestamptz and last_scanned_by uuid (FK → auth.users.id). public.profiles has id uuid (FK → auth.users.id) + full_name text. The migration adds only last_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 main first; Plings-iOS tasks (7–12) merge to main after 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 — extend log_scan_event (geo + scanning_user + owner_at_time_of_scan); add update_last_scanned; wire both into resolve_identifier.
  • Modify: app/graphql.py — add type LastSeen + lastSeenInfo: LastSeen on ObjectInstance.
  • Modify: app/object_detail_resolvers.py — add build_last_seen_info helper; extend the getObjectDetailsComplete SQL + 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.swiftgeoLocation on ResolveIdentifierOperation; lastSeenInfo in GetObjectDetailsOperation.document.
  • Modify: PlingsKit/Sources/PlingsKit/API/Models.swiftLastSeenInfo on ObjectDetails.
  • Create test: PlingsKit/Tests/PlingsKitTests/ScanGeoEncodingTests.swift.
  • Create: App/Support/LocationManager.swift.
  • Modify: App/project.ymlNSLocationWhenInUseUsageDescription.
  • Modify: App/AppModel.swift — expose currentUserId; hold a LocationManager.
  • 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 (schema public) shows last_scanned_latitude / last_scanned_longitude on object_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_event in app/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 (above log_scan_event) and the text import

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 on ObjectInstance

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 getObjectDetailsComplete SQL + 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 (targets dev). Update them in a Plings-Web worktree (Task 11b below covers committing them on dev); the Plings-API/ROADMAP.md change 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 geoLocation to ResolveIdentifierOperation

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 lastSeenInfo to 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?
}

ObjectDetails is Decodable; an added optional decodes to nil when 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,  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 + location to AppModel

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.swift and the test file are new files — per CLAUDE.md, the user’s local checkout needs xcodegen generate after the FF or ⌘R fails with Cannot 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 -q green; migration applied (with Paul’s go-ahead); QC passed; merged to main and live.
  • Plings-iOS: cd PlingsKit && swift test green; app builds; QC passed; merged to main after the API; local checkout FF’d + xcodegen generate run.
  • 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).