Public Scan Landing Page — Phase 1 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: Ship the browser-path scan landing page — a public, status-aware object page plus onboarding and error pages — backed by real scan-event persistence, without geolocation collection (Phase 2) or the contact-owner flow (Phase 3).

Architecture: The Gateway keeps 302-redirecting. A new unauthenticated publicObject(id) GraphQL query feeds a React page at /o/:oid; unknown tags land on /welcome (with a stubbed class-aware branch) and invalid/unverifiable codes on /scan-error. The backend’s resolveIdentifier now persists a real :ScanEvent in Neo4j and returns its id as sid. Class lookup and genuineness verification are built as clearly-marked stubs.

Tech Stack: Plings-API (Python 3.10, Ariadne GraphQL, Neo4j async driver, SQLAlchemy/Postgres, pytest + unittest.mock); Plings-Gateway (TypeScript, Vercel Edge, jest); Plings-Web (React, TypeScript, Vite, Tailwind, shadcn/ui, Apollo Client).

Spec: Plings-Web/docs/specs/2026-06-14-scan-landing-page-design.md


Scope, repos, and conventions

  • This plan is Phase 1 only. Phase 2 (geolocation collection + GDPR) and Phase 3 (contact-owner) get their own plans.
  • Cross-repo. Tasks touch three repos. Per CLAUDE.md: commit each repo separately; Plings-Web targets the dev branch, Plings-API and Plings-Gateway target main. Stage explicit paths — never git add -A (these trees carry unrelated WIP). Do not push unless the user asks (push = live deploy).
  • Worktrees. If isolating, create a worktree per repo via the superpowers:using-git-worktrees skill before starting.
  • Frontend has no test runner. Plings-Web has no vitest/jest/testing-library. Backend tasks (API, Gateway) use full TDD. Frontend tasks verify via npm run type-check/build and the dev-server preview workflow instead of unit tests. Adding a frontend test runner is out of scope for Phase 1.
  • Local dev loop: API cd Plings-API && poetry run uvicorn app.main:app --reload --port 8000; Web cd Plings-Web && npm run dev:local (Vite on :8080, points at localhost:8000). Databases are cloud-hosted — no local DB.

File structure (created / modified)

Plings-API

  • Modify app/scan_resolvers_fixed.py — real :ScanEvent persistence; return sid; genuineness-hook call; route all known objects to /o/:oid.
  • Create app/genuineness.py — stubbed genuineness-verification hook (TODO(genuineness-verification)).
  • Create app/public_resolvers.pypublicObject(id) query + publicClass(classPointer) stub query.
  • Modify app/graphql.py — add SDL types (PublicObject, PublicObjectClass, PublicOwner, PublicLastSeen, PublicClass) + Query fields; register the new QueryType in make_executable_schema.
  • Create tests: tests/test_scan_persistence.py, tests/test_public_object.py, tests/test_public_class_stub.py, tests/test_genuineness_stub.py.

Plings-Gateway

  • Modify tests/gateway-handler.test.ts — assert /o/:oid destination + oid/sid pass-through in the backend-integration block. (No production-code change: the backend supplies destination + sid in routing.parameters, which the Gateway already forwards.)

Plings-Web

  • Create src/graphql/public.tsPUBLIC_OBJECT, PUBLIC_CLASS documents + TS types.
  • Create src/lib/openInApp.ts — deep-link helper with store fallback.
  • Create src/pages/scan/ObjectLanding.tsx/o/:oid status-aware page.
  • Create src/pages/scan/Welcome.tsx/welcome onboarding (+ class-aware branch).
  • Create src/pages/scan/ScanError.tsx/scan-error genuineness page.
  • Modify src/App.tsx — register the three public routes.

Task 1: Persist scan events in Neo4j (Plings-API)

Replace the stubbed log_scan_event (currently writes a log line + returns a mock id) with real persistence of a :ScanEvent node and the (:ScanEvent)-[:SCANNED]->(:PlingsIdentifier) relationship, returning a real id. The function must accept the Neo4j session so it can be tested with a mock.

Files:

  • Modify: Plings-API/app/scan_resolvers_fixed.py
  • Test: Plings-API/tests/test_scan_persistence.py

  • Step 1: Write the failing test
# Plings-API/tests/test_scan_persistence.py
import asyncio
import types
from unittest.mock import AsyncMock, MagicMock
from app import scan_resolvers_fixed as sri


def _session(returned_id="scan_123"):
    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_and_returns_real_id():
    session = _session("scan_abc")
    scan_data = {
        "type": "q",
        "identifier": "4kyQCd5tMDjJVWJH5h95gUcjq3qTX2cj5nwjVyqBRwLo",
        "path": "4.2.3.3.6",
        "scanMetadata": {"timestamp": "2026-06-14T08:00:00Z", "ipAddress": "1.2.3.4"},
    }
    routing = {"action": "REDIRECT", "destination": "https://www.plings.io/o/obj-1", "object_id": "obj-1"}

    out = asyncio.run(sri.log_scan_event(scan_data, routing, session))

    assert out == "scan_abc"
    # A write Cypher was executed with the instanceKey and object id
    args, kwargs = session.run.call_args
    cypher = args[0]
    assert "ScanEvent" in cypher and "SCANNED" in cypher
    assert kwargs["ikey"] == scan_data["identifier"]
    assert kwargs["oid"] == "obj-1"
  • Step 2: Run test to verify it fails

Run: cd Plings-API && poetry run pytest tests/test_scan_persistence.py -v Expected: FAIL — log_scan_event() currently takes 2 args (no session) and does not call session.run.

  • Step 3: Rewrite log_scan_event

Replace the existing async def log_scan_event(...) body in app/scan_resolvers_fixed.py with:

async def log_scan_event(
    scan_data: Dict[str, Any],
    routing_decision: Dict[str, Any],
    neo4j_session,
) -> str:
    """Persist a :ScanEvent and (:ScanEvent)-[:SCANNED]->(:PlingsIdentifier).

    Phase 1: timestamp, transport, hashed IP and resolved object id are stored.
    Geolocation (latitude/longitude/accuracy) is added in Phase 2 via
    recordScanLocation; scanning_user is set by the native path.
    """
    meta = scan_data.get("scanMetadata", {}) or {}
    scan_id = f"scan_{datetime.utcnow().timestamp()}"
    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
        })
        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 (datetime.utcnow().isoformat() + "Z"),
        "transport": scan_data.get("type"),
        "ip_hash": hash_ip_address(meta.get("ipAddress", "")),
        "oid": routing_decision.get("object_id"),
    }
    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: Update the single caller inside resolve_identifier

In resolve_identifier, the log_scan_event(input, routing_decision) call is inside the async with neo4j_driver.session() as neo4j_session: block. Pass the session and add object_id to routing_decision first (so the event records it):

            # ensure the resolved object id is on the routing dict for logging
            routing_decision["object_id"] = verification_result.get("object_id")

            # Log scan event (persists :ScanEvent)
            scan_event_id = await log_scan_event(
                input,
                routing_decision,
                neo4j_session,
            )
  • Step 5: Run test to verify it passes

Run: cd Plings-API && poetry run pytest tests/test_scan_persistence.py -v Expected: PASS.

  • Step 6: Run the existing scan auth tests to confirm no regression

Run: cd Plings-API && poetry run pytest tests/test_resolve_identifier_auth.py -v Expected: PASS — those tests patch log_scan_event with an AsyncMock, so the new signature is tolerated.

  • Step 7: Commit
cd Plings-API
git add app/scan_resolvers_fixed.py tests/test_scan_persistence.py
git commit -m "feat(scan): persist :ScanEvent in Neo4j and return real scan id"

Task 2: Genuineness-verification hook (stub) (Plings-API)

Create the named seam where Ed25519 / manufacturer verification will live, returning an explicit “unknown” verdict for now. Wire it into the resolve path so its result is available, but do not change routing on it yet (real verification is future work).

Files:

  • Create: Plings-API/app/genuineness.py
  • Modify: Plings-API/app/scan_resolvers_fixed.py
  • Test: Plings-API/tests/test_genuineness_stub.py

  • Step 1: Write the failing test
# Plings-API/tests/test_genuineness_stub.py
from app.genuineness import verify_genuineness, GenuinenessVerdict


def test_genuineness_hook_returns_unknown_for_now():
    verdict = verify_genuineness(
        identifier="4kyQCd5tMDjJVWJH5h95gUcjq3qTX2cj5nwjVyqBRwLo",
        path="4.2.3.3.6",
        class_pointer=None,
    )
    assert isinstance(verdict, GenuinenessVerdict)
    assert verdict.status == "unknown"
    assert verdict.verified is False
  • Step 2: Run test to verify it fails

Run: cd Plings-API && poetry run pytest tests/test_genuineness_stub.py -v Expected: FAIL — module app.genuineness does not exist.

  • Step 3: Create the stub module
# Plings-API/app/genuineness.py
"""Genuineness verification hook.

TODO(genuineness-verification): implement real verification against the
HD-wallet / manufacturer layer (Ed25519 manufacturer signatures, the
key_delegation_level none|verification|full chain). See
docs/database/hd-wallet-schema.md and docs/security/. Until then this returns
an explicit "unknown" verdict and routing must NOT treat scans as forgeries.
"""
from dataclasses import dataclass
from typing import Optional, Literal


@dataclass
class GenuinenessVerdict:
    status: Literal["genuine", "unverified", "unknown"]
    verified: bool


def verify_genuineness(
    identifier: str,
    path: str,
    class_pointer: Optional[str],
) -> GenuinenessVerdict:
    """Stub: cannot verify yet, so report 'unknown' (not 'unverified')."""
    return GenuinenessVerdict(status="unknown", verified=False)
  • Step 4: Call the hook in resolve_identifier (no routing change)

After verification_result = await verify_identifier(...) in resolve_identifier, add:

            # Genuineness hook (stubbed — see app/genuineness.py).
            # TODO(genuineness-verification): route reason=unverified when this
            # returns status == "unverified".
            from .genuineness import verify_genuineness
            genuineness = verify_genuineness(identifier, path, class_pointer)
            logger.info(f"Genuineness verdict: {genuineness.status}")
  • Step 5: Run test to verify it passes

Run: cd Plings-API && poetry run pytest tests/test_genuineness_stub.py -v Expected: PASS.

  • Step 6: Commit
cd Plings-API
git add app/genuineness.py app/scan_resolvers_fixed.py tests/test_genuineness_stub.py
git commit -m "feat(scan): add stubbed genuineness-verification hook"

Task 3: publicObject(id) query (Plings-API)

A new unauthenticated query returning a curated, public subset. Postgres supplies name / image / owner-org; Neo4j supplies statuses. lastSeen returns the scan timestamp only in Phase 1 (no coordinates yet). owner.verified is a Phase-1 derivation (org type ≠ INDIVIDUAL) with a TODO.

Files:

  • Create: Plings-API/app/public_resolvers.py
  • Modify: Plings-API/app/graphql.py
  • Test: Plings-API/tests/test_public_object.py

  • Step 1: Add SDL types and Query fields in graphql.py

Inside the type_defs string, add these types (place them next to ObjectContext):

    type PublicObject {
        id: ID!
        name: String!
        mainImageUrl: String
        status: String!
        statuses: [String!]!
        objectClass: PublicObjectClass
        owner: PublicOwner
        lastSeen: PublicLastSeen
    }
    type PublicObjectClass { name: String, category: String }
    type PublicOwner { name: String, verified: Boolean! }
    type PublicLastSeen { area: String, coarseLatitude: Float, coarseLongitude: Float, at: String }
    type PublicClass { id: ID!, name: String!, category: String, description: String, imageUrl: String }

Add to type Query { ... }:

        publicObject(id: ID!): PublicObject
        publicClass(classPointer: String!): PublicClass
  • Step 2: Register the new resolver in make_executable_schema

At the top of graphql.py with the other resolver imports, add:

from .public_resolvers import query as public_query

Add public_query, to the argument list of make_executable_schema(type_defs, ...).

  • Step 3: Write the failing test
# Plings-API/tests/test_public_object.py
import asyncio
import types
from unittest.mock import AsyncMock, MagicMock
from app import public_resolvers as pr


def _pg_engine(obj_row, image_row):
    conn = MagicMock()
    # full object row
    full = MagicMock(); full.mappings.return_value.one = MagicMock(return_value=obj_row)
    # main image row (or None)
    img = MagicMock(); img.fetchone = MagicMock(return_value=image_row)
    conn.execute = AsyncMock(side_effect=[full, img])
    cm = MagicMock(); cm.__aenter__ = AsyncMock(return_value=conn); cm.__aexit__ = AsyncMock(return_value=False)
    engine = MagicMock(); engine.begin = MagicMock(return_value=cm)
    return engine


def _neo4j(status_keys):
    session = MagicMock(); result = MagicMock()
    result.single = AsyncMock(return_value={"statusKeys": status_keys})
    session.run = AsyncMock(return_value=result)
    cm = MagicMock(); cm.__aenter__ = AsyncMock(return_value=session); cm.__aexit__ = AsyncMock(return_value=False)
    driver = MagicMock(); driver.session = MagicMock(return_value=cm)
    return driver


def _info(engine, driver):
    return types.SimpleNamespace(context={"postgres": engine, "neo4j": driver, "user": None})


def test_public_object_returns_curated_subset():
    obj_row = {"id": "obj-1", "name": "Trek Marlin 7",
               "organization_name": "Grönteknik AB", "organization_type": "Business",
               "owner_organization_id": "org-1", "last_scanned_at": None}
    # image row is a tuple-like Row; resolver reads img_row[0]
    info = _info(_pg_engine(obj_row, ("https://img/x.jpg",)), _neo4j(["FOR_SALE"]))

    out = asyncio.run(pr.resolve_public_object(None, info, id="obj-1"))

    assert out["name"] == "Trek Marlin 7"
    assert out["mainImageUrl"] == "https://img/x.jpg"
    assert out["status"] == "FOR_SALE"
    assert out["owner"] == {"name": "Grönteknik AB", "verified": True}


def test_public_object_missing_returns_none():
    engine = MagicMock()
    cm = MagicMock(); conn = MagicMock()
    conn.execute = AsyncMock(side_effect=Exception("no rows"))
    cm.__aenter__ = AsyncMock(return_value=conn); cm.__aexit__ = AsyncMock(return_value=False)
    engine.begin = MagicMock(return_value=cm)
    info = _info(engine, _neo4j([]))
    out = asyncio.run(pr.resolve_public_object(None, info, id="missing"))
    assert out is None
  • Step 4: Run test to verify it fails

Run: cd Plings-API && poetry run pytest tests/test_public_object.py -v Expected: FAIL — app.public_resolvers does not exist.

  • Step 5: Create public_resolvers.py
# Plings-API/app/public_resolvers.py
"""Unauthenticated, curated public resolvers for the scan landing page.

publicObject(id) returns ONLY a safe subset — never the full ObjectInstance.
publicClass(classPointer) is a stub until the class system exists.
"""
from typing import Any, Dict, Optional
from ariadne import QueryType
from sqlalchemy import text
from .logging_config import logger

query = QueryType()

_DB_TYPE_VERIFIED = {"Business", "Organization"}  # Phase-1 derivation


def _primary_status(status_keys: list[str]) -> str:
    for s in ("FOR_SALE", "FOR_RENT", "LENDABLE", "LOST"):
        if s in status_keys:
            return s
    return "ACTIVE"


@query.field("publicObject")
async def resolve_public_object(_: Any, info, id: str) -> Optional[Dict[str, Any]]:
    pg = info.context["postgres"]
    try:
        async with pg.begin() as conn:
            full = await conn.execute(
                text(
                    '''SELECT oi.id, oi.name, oi.owner_organization_id, oi."last_scanned_at",
                              o.name AS organization_name, o.type AS organization_type
                       FROM object_instances oi
                       LEFT JOIN organizations o ON oi.owner_organization_id = o.id
                       WHERE oi.id = :oid'''
                ),
                {"oid": id},
            )
            obj = full.mappings().one()  # raises if no row -> caught below
            img = await conn.execute(
                text(
                    '''SELECT public_url FROM object_images
                       WHERE object_instance_id = :oid AND is_main = true LIMIT 1'''
                ),
                {"oid": id},
            )
            img_row = img.fetchone()
            main_image_url = img_row[0] if img_row else None
    except Exception as e:
        logger.info(f"publicObject({id}) not found: {e}")
        return None

    # Statuses from Neo4j
    status_keys: list[str] = []
    try:
        driver = info.context.get("neo4j")
        async with driver.session() as session:
            res = await session.run(
                """MATCH (obj:ObjectInstance {id: $oid})
                   OPTIONAL MATCH (obj)-[:HAS_STATUS]->(s:ObjectStatus)
                   RETURN collect(s.key) AS statusKeys""",
                oid=id,
            )
            rec = await res.single()
            status_keys = (rec and rec["statusKeys"]) or []
    except Exception as e:
        logger.error(f"publicObject status lookup failed for {id}: {e}")

    owner = None
    if obj["owner_organization_id"] and obj["organization_name"]:
        # TODO(genuineness-verification): replace derived flag with real
        # manufacturer/identity verification.
        verified = obj["organization_type"] in _DB_TYPE_VERIFIED
        owner = {"name": obj["organization_name"], "verified": bool(verified)}

    last_seen = None
    if obj["last_scanned_at"]:
        # Phase 1: timestamp only. Coordinates/area arrive in Phase 2.
        last_seen = {"area": None, "coarseLatitude": None,
                     "coarseLongitude": None, "at": obj["last_scanned_at"].isoformat()}

    return {
        "id": str(obj["id"]),
        "name": obj["name"],
        "mainImageUrl": main_image_url,
        "status": _primary_status(status_keys),
        "statuses": status_keys,
        "objectClass": None,  # TODO(class-system): populate when classes exist
        "owner": owner,
        "lastSeen": last_seen,
    }
  • Step 6: Run test to verify it passes

Run: cd Plings-API && poetry run pytest tests/test_public_object.py -v Expected: PASS (both cases).

  • Step 7: Sanity-check the schema assembles

Run: cd Plings-API && poetry run python -c "import app.graphql" Expected: no exception (SDL parses, resolver registered).

  • Step 8: Commit
cd Plings-API
git add app/public_resolvers.py app/graphql.py tests/test_public_object.py
git commit -m "feat(api): add unauthenticated publicObject(id) query"

Task 4: publicClass(classPointer) stub query (Plings-API)

Stub the class lookup so the frontend’s class-aware /welcome branch can be wired now.

Files:

  • Modify: Plings-API/app/public_resolvers.py
  • Test: Plings-API/tests/test_public_class_stub.py

  • Step 1: Write the failing test
# Plings-API/tests/test_public_class_stub.py
import asyncio, types
from app import public_resolvers as pr


def test_public_class_stub_returns_none():
    info = types.SimpleNamespace(context={})
    out = asyncio.run(pr.resolve_public_class(None, info, classPointer="4K7mX9abDcE"))
    assert out is None
  • Step 2: Run test to verify it fails

Run: cd Plings-API && poetry run pytest tests/test_public_class_stub.py -v Expected: FAIL — resolve_public_class does not exist.

  • Step 3: Add the stub resolver to public_resolvers.py
@query.field("publicClass")
async def resolve_public_class(_: Any, info, classPointer: str) -> Optional[Dict[str, Any]]:
    # TODO(class-system): resolve the class pointer to general class info once
    # the class system exists. Until then the frontend treats null as "unknown
    # class" and shows generic onboarding.
    logger.info(f"publicClass stub called for pointer {classPointer!r} -> None")
    return None
  • Step 4: Run test to verify it passes

Run: cd Plings-API && poetry run pytest tests/test_public_class_stub.py -v Expected: PASS.

  • Step 5: Commit
cd Plings-API
git add app/public_resolvers.py tests/test_public_class_stub.py
git commit -m "feat(api): add publicClass(classPointer) stub query"

Task 5: Route known objects to /o/:oid and expose sid (Plings-API + Gateway)

Today resolve_identifier already routes a known NORMAL object to https://www.plings.io/o/{object_id}, but Lost/Sale/Rent go elsewhere. Converge all known objects onto /o/:oid (the page branches on status, per spec §2.1) and add the scan-event id as a sid parameter so the Gateway forwards it unchanged.

Files:

  • Modify: Plings-API/app/scan_resolvers_fixed.py
  • Modify: Plings-Gateway/tests/gateway-handler.test.ts

  • Step 1: Converge routing in resolve_identifier

In the “Valid identifier” branch, replace the per-status base_url selection with: known object → /o/:oid; unknown → /welcome. Keep FOR_SALE/RENT off their old subdomains for v1.

                object_id = verification_result.get("object_id")
                object_status = verification_result.get("object_status", "UNKNOWN")

                if object_id:
                    # All known objects land on the status-aware public page.
                    base_url = f"https://www.plings.io/o/{object_id}"
                else:
                    base_url = "https://www.plings.io/welcome"
  • Step 2: Add sid to the redirect parameters

Task 1 step 4 already set routing_decision["object_id"] and changed the log_scan_event(...) call to pass neo4j_session, producing scan_event_id. Here, add only the sid append immediately after that existing call (still inside the with block, before response is built):

            scan_event_id = await log_scan_event(input, routing_decision, neo4j_session)

            # Forward the scan id so the landing page can enrich it in Phase 2.
            if routing_decision.get("parameters") is not None:
                routing_decision["parameters"].append({"key": "sid", "value": scan_event_id})
  • Step 3: Update/extend the API auth tests for the new destination

The existing tests/test_resolve_identifier_auth.py asserts out["object"]["objectId"]. Add one assertion in test_authenticated_user_resolves_known_tag after the existing asserts:

    assert out["routing"]["destination"] == "https://www.plings.io/o/obj-1"
    assert any(p["key"] == "sid" for p in out["routing"]["parameters"])
  • Step 4: Run API tests

Run: cd Plings-API && poetry run pytest tests/test_resolve_identifier_auth.py tests/test_scan_persistence.py -v Expected: PASS.

  • Step 5: Extend the Gateway backend-integration test

In Plings-Gateway/tests/gateway-handler.test.ts, locate the backend-integration describe block (it re-imports the handler with USE_BACKEND_API enabled and a mocked fetch). Add a test that the mocked backend’s destination + parameters (including oid and sid) are forwarded into the 302 Location. Use the same mock shape already present in that block; the assertion:

  it('forwards the backend /o/:oid destination with oid and sid', async () => {
    // backend mock returns routing.destination = https://www.plings.io/o/obj-1
    // with parameters [{key:'oid',value:'obj-1'},{key:'sid',value:'scan_x'}]
    const response = await handlerWithBackend(scanRequest(`t=q&i=${VALID_I}&p=${VALID_P}`, '10.0.1.1'));
    const loc = locationOf(response);
    expect(loc.pathname).toBe('/o/obj-1');
    expect(loc.searchParams.get('oid')).toBe('obj-1');
    expect(loc.searchParams.get('sid')).toBe('scan_x');
  });

If the existing block’s mock returns a different shape, mirror its existing mock and adjust the expected destination/parameters to match what you set. (No Gateway production code changes — it already forwards routing.destination + routing.parameters.)

  • Step 6: Run Gateway tests

Run: cd Plings-Gateway && npm test Expected: PASS.

  • Step 7: Commit (two repos, separately)
cd Plings-API
git add app/scan_resolvers_fixed.py tests/test_resolve_identifier_auth.py
git commit -m "feat(scan): route all known objects to /o/:oid and forward sid"

cd ../Plings-Gateway
git add tests/gateway-handler.test.ts
git commit -m "test(gateway): assert /o/:oid destination and oid/sid pass-through"

Task 6: Public GraphQL documents (Plings-Web)

Files:

  • Create: Plings-Web/src/graphql/public.ts

  • Step 1: Create the documents and types

// Plings-Web/src/graphql/public.ts
import { gql } from '@apollo/client';

export const PUBLIC_OBJECT = gql`
  query PublicObject($id: ID!) {
    publicObject(id: $id) {
      id
      name
      mainImageUrl
      status
      statuses
      objectClass { name category }
      owner { name verified }
      lastSeen { area coarseLatitude coarseLongitude at }
    }
  }
`;

export const PUBLIC_CLASS = gql`
  query PublicClass($classPointer: String!) {
    publicClass(classPointer: $classPointer) {
      id name category description imageUrl
    }
  }
`;

export interface PublicObject {
  id: string;
  name: string;
  mainImageUrl: string | null;
  status: string;
  statuses: string[];
  objectClass: { name: string | null; category: string | null } | null;
  owner: { name: string | null; verified: boolean } | null;
  lastSeen: { area: string | null; coarseLatitude: number | null; coarseLongitude: number | null; at: string | null } | null;
}

export interface PublicClass {
  id: string; name: string; category: string | null; description: string | null; imageUrl: string | null;
}
  • Step 2: Type-check

Run: cd Plings-Web && npm run type-check Expected: no errors.

  • Step 3: Commit
cd Plings-Web
git add src/graphql/public.ts
git commit -m "feat(web): add public scan-landing GraphQL documents"

Files:

  • Create: Plings-Web/src/lib/openInApp.ts

  • Step 1: Create the helper

// Plings-Web/src/lib/openInApp.ts
// Attempts to open the native Plings app via a custom scheme; if nothing
// handles it within the timeout, falls back to the appropriate store.
// Phase 1: simple scheme + store fallback. Universal-link handshake is refined
// when the native app ships.
const APP_STORE_URL = 'https://apps.apple.com/app/plings/id000000000';
const PLAY_STORE_URL = 'https://play.google.com/store/apps/details?id=io.plings.app';

export function storeUrlForPlatform(ua: string = navigator.userAgent): string {
  return /android/i.test(ua) ? PLAY_STORE_URL : APP_STORE_URL;
}

export function openInApp(params: Record<string, string>): void {
  const qs = new URLSearchParams(params).toString();
  const deepLink = `plings://scan?${qs}`;
  const fallback = storeUrlForPlatform();
  const timer = window.setTimeout(() => { window.location.href = fallback; }, 1200);
  // If the app opens, the page is backgrounded and the timer is cleared on hide.
  const onHide = () => { if (document.hidden) window.clearTimeout(timer); };
  document.addEventListener('visibilitychange', onHide, { once: true });
  window.location.href = deepLink;
}
  • Step 2: Type-check

Run: cd Plings-Web && npm run type-check Expected: no errors.

  • Step 3: Commit
cd Plings-Web
git add src/lib/openInApp.ts
git commit -m "feat(web): add openInApp deep-link helper with store fallback"

Task 8: /o/:oid status-aware landing page (Plings-Web)

Files:

  • Create: Plings-Web/src/pages/scan/ObjectLanding.tsx

  • Step 1: Create the page

Uses shadcn Card/Button (@/components/ui/*) and Tailwind tokens. Renders the four content blocks from spec §2.1; location block only when lastSeen has coordinates (Phase 1: hidden).

// Plings-Web/src/pages/scan/ObjectLanding.tsx
import { useParams, useSearchParams } from 'react-router-dom';
import { useQuery } from '@apollo/client';
import { PUBLIC_OBJECT, PublicObject } from '@/graphql/public';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { openInApp } from '@/lib/openInApp';

const STATUS_LABEL: Record<string, string> = {
  ACTIVE: 'Aktiv', LOST: 'Borttappad', FOR_SALE: 'Till salu',
  FOR_RENT: 'Går att hyra', LENDABLE: 'Går att låna',
};

export default function ObjectLanding() {
  const { oid = '' } = useParams();
  const [sp] = useSearchParams();
  const { data, loading, error } = useQuery<{ publicObject: PublicObject | null }>(
    PUBLIC_OBJECT, { variables: { id: oid } });

  if (loading) return <CenteredMessage title="Laddar…" />;
  const obj = data?.publicObject;
  if (error || !obj) return <CenteredMessage title="Hittades inte"
    subtitle="Vi kunde inte hitta den här saken. Taggen kan ha tagits bort." />;

  const isLost = obj.status === 'LOST';
  const isCommerce = ['FOR_SALE', 'FOR_RENT', 'LENDABLE'].includes(obj.status);
  const open = () => openInApp({ oid: obj.id });

  return (
    <div className="min-h-screen bg-background flex justify-center p-4">
      <Card className="w-full max-w-sm overflow-hidden">
        <div className="relative h-44 bg-muted flex items-center justify-center">
          {obj.mainImageUrl
            ? <img src={obj.mainImageUrl} alt={obj.name} className="h-full w-full object-cover" />
            : <span className="text-muted-foreground text-sm">Ingen bild</span>}
          <span className={`absolute top-3 left-3 rounded-full px-3 py-1 text-xs font-semibold text-white
            ${isLost ? 'bg-destructive' : isCommerce ? 'bg-secondary' : 'bg-primary'}`}>
            {STATUS_LABEL[obj.status] ?? obj.status}
          </span>
        </div>
        <div className="p-4 space-y-4">
          <div>
            <h1 className="text-xl font-bold">{obj.name}</h1>
            {obj.objectClass?.name && (
              <p className="text-sm text-muted-foreground">
                {[obj.objectClass.name, obj.objectClass.category].filter(Boolean).join(' · ')}
              </p>)}
          </div>

          {isLost && (
            <div className="rounded-lg border border-destructive/35 bg-destructive/10 px-3 py-2 text-sm text-destructive">
              🔎 Markerad som borttappad. Hittade du den? Hjälp den hem.
            </div>)}
          {isCommerce && (
            <div className="rounded-lg border border-secondary/40 bg-secondary/15 px-3 py-2 text-sm">
              🏷️ {STATUS_LABEL[obj.status]} — affären sker i Plings-appen.
            </div>)}

          {obj.lastSeen?.coarseLatitude != null && (
            <div className="rounded-lg border p-3 text-sm">
              <div className="font-semibold">
                {obj.lastSeen.area ? `Senast sedd i ${obj.lastSeen.area}` : 'Senast sedd'}
              </div>
              {obj.lastSeen.at && <div className="text-muted-foreground text-xs">{obj.lastSeen.at}</div>}
            </div>)}

          <div className="space-y-2">
            {isLost ? (
              <>
                <Button className="w-full bg-destructive hover:bg-destructive/90" disabled
                  title="Kommer i fas 3">Jag har hittat den — kontakta ägaren</Button>
                <Button variant="outline" className="w-full" onClick={open}>Öppna i appen</Button>
              </>) : (
              <>
                <Button className="w-full" onClick={open}>Öppna i Plings-appen</Button>
                <Button variant="outline" className="w-full" onClick={open}>Ladda ner appen</Button>
                <button className="w-full text-sm text-muted-foreground underline disabled:opacity-60"
                  disabled title="Kommer i fas 3">Anmäl som upphittat · kontakta ägaren</button>
              </>)}
          </div>

          {obj.owner?.name && (
            <div className="flex items-center justify-between border-t pt-3 text-sm">
              <span>Ägs av <strong>{obj.owner.name}</strong></span>
              {obj.owner.verified && <span className="text-primary text-xs font-semibold">✓ verifierad</span>}
            </div>)}
        </div>
        <div className="border-t p-3 text-center text-xs text-muted-foreground">Identifierad av Plings</div>
      </Card>
    </div>
  );
}

function CenteredMessage({ title, subtitle }: { title: string; subtitle?: string }) {
  return (
    <div className="min-h-screen flex items-center justify-center bg-background p-4">
      <div className="text-center">
        <h1 className="text-2xl font-bold mb-2">{title}</h1>
        {subtitle && <p className="text-muted-foreground">{subtitle}</p>}
      </div>
    </div>);
}

The “kontakta ägaren / anmäl upphittat” actions are intentionally disabled in Phase 1 (the mutation arrives in Phase 3). They render so the layout is final.

  • Step 2: Type-check

Run: cd Plings-Web && npm run type-check Expected: no errors.

  • Step 3: Commit
cd Plings-Web
git add src/pages/scan/ObjectLanding.tsx
git commit -m "feat(web): add status-aware /o/:oid scan landing page"

Task 9: /welcome and /scan-error pages (Plings-Web)

Files:

  • Create: Plings-Web/src/pages/scan/Welcome.tsx
  • Create: Plings-Web/src/pages/scan/ScanError.tsx

  • Step 1: Create Welcome.tsx (with stubbed class-aware branch)
// Plings-Web/src/pages/scan/Welcome.tsx
import { useSearchParams } from 'react-router-dom';
import { useQuery } from '@apollo/client';
import { PUBLIC_CLASS, PublicClass } from '@/graphql/public';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { openInApp } from '@/lib/openInApp';

export default function Welcome() {
  const [sp] = useSearchParams();
  const ikey = sp.get('ikey') ?? '';
  const path = sp.get('path') ?? '';
  const cptr = sp.get('cptr') ?? '';

  // Class-aware branch is wired but inactive until the class system exists
  // (publicClass currently returns null). See spec §2.2 / §2.5.
  const { data } = useQuery<{ publicClass: PublicClass | null }>(
    PUBLIC_CLASS, { variables: { classPointer: cptr }, skip: !cptr });
  const klass = data?.publicClass ?? null;

  const open = () => openInApp({ i: ikey, p: path, ...(cptr ? { cp: cptr } : {}) });

  return (
    <div className="min-h-screen bg-background flex justify-center p-4">
      <Card className="w-full max-w-sm overflow-hidden text-center">
        <div className="pt-7 px-4">
          <div className="mx-auto mb-3 h-14 w-14 rounded-2xl bg-primary text-primary-foreground
            flex items-center justify-center text-2xl font-bold">P</div>
          <h1 className="text-xl font-bold">
            {klass ? 'Vi vet vad det här är' : 'Den här saken är Plings-märkt'}
          </h1>
        </div>
        <p className="text-sm text-muted-foreground px-5 my-4">
          {klass
            ? `Det här är en ${[klass.name, klass.category].filter(Boolean).join(' · ')}, men ingen har skapat just den här i Plings ännu.`
            : '…men ingen har skapat den i Plings ännu. Plings ger varje sak en identitet — så att den kan hittas, återanvändas och följas genom livet.'}
        </p>
        <div className="px-4 pb-4 space-y-2 text-left">
          <ol className="space-y-2 text-sm mb-2">
            <li>1. Ladda ner Plings-appen</li>
            <li>2. Skanna taggen igen i appen</li>
            <li>3. Skapa objektet{klass ? ' med alla dess egenskaper' : ''} och gör det till ditt</li>
          </ol>
          <Button className="w-full" onClick={open}>
            {klass ? 'Skapa objektet i appen' : 'Ladda ner appen & skapa objektet'}
          </Button>
          <Button variant="outline" className="w-full" onClick={open}>Har du redan appen? Öppna den</Button>
        </div>
        <div className="border-t p-3 text-center text-xs text-muted-foreground">Identifierad av Plings</div>
      </Card>
    </div>
  );
}
  • Step 2: Create ScanError.tsx (genuineness messaging)
// Plings-Web/src/pages/scan/ScanError.tsx
import { useSearchParams } from 'react-router-dom';
import { Card } from '@/components/ui/card';

export default function ScanError() {
  const [sp] = useSearchParams();
  const reason = sp.get('reason') ?? 'invalid';
  const unverified = sp.get('details') === 'unverified' || reason === 'unverified';

  return (
    <div className="min-h-screen bg-background flex justify-center p-4">
      <Card className="w-full max-w-sm p-6 text-center space-y-3">
        <div className="mx-auto h-12 w-12 rounded-full bg-muted flex items-center justify-center text-2xl">⚠️</div>
        <h1 className="text-xl font-bold">
          {unverified ? 'Den här koden kunde inte verifieras' : 'Koden kunde inte läsas'}
        </h1>
        <p className="text-sm text-muted-foreground">
          Plings finns för att garantera att fysiska saker är äkta. Den garantin bygger på att
          koden skapats och placerats av en betrodd källa som känner sakens äkthet. En kod vi inte
          kan verifiera bör hanteras med försiktighet — men oftast är det bara en skadad eller
          felinläst kod.
        </p>
        <a className="text-sm text-primary underline" href="https://plings.io/akthet">
          Läs mer om hur Plings säkrar äkthet
        </a>
      </Card>
    </div>
  );
}

The authenticity explainer link target (/akthet) does not exist yet — that page is future work (spec §2.4). Underlying concepts: docs/database/hd-wallet-schema.md, docs/security/.

  • Step 3: Type-check

Run: cd Plings-Web && npm run type-check Expected: no errors.

  • Step 4: Commit
cd Plings-Web
git add src/pages/scan/Welcome.tsx src/pages/scan/ScanError.tsx
git commit -m "feat(web): add /welcome onboarding and /scan-error genuineness pages"

Task 10: Register routes and verify end-to-end (Plings-Web)

Files:

  • Modify: Plings-Web/src/App.tsx

  • Step 1: Import the pages

Add with the other page imports near the top of src/App.tsx:

import ObjectLanding from "./pages/scan/ObjectLanding";
import Welcome from "./pages/scan/Welcome";
import ScanError from "./pages/scan/ScanError";
  • Step 2: Add the routes

Inside <Routes>, alongside <Route path="/" .../>:

                  <Route path="/o/:oid" element={<ObjectLanding />} />
                  <Route path="/welcome" element={<Welcome />} />
                  <Route path="/scan-error" element={<ScanError />} />
  • Step 3: Type-check and build

Run: cd Plings-Web && npm run type-check && npm run build Expected: both succeed.

  • Step 4: Verify in the dev server (preview workflow)

Start the API (cd Plings-API && poetry run uvicorn app.main:app --reload --port 8000) and Web (cd Plings-Web && npm run dev:local). Then, using the preview tools:

  • Load /o/<a real object id> → status-aware card renders (image, name, status badge, owner, app buttons). Check the console/network for the publicObject query 200.
  • Load /welcome?ikey=AAA&path=4.2.3.2.4 → onboarding renders; publicClass is skipped (no cptr).
  • Load /welcome?ikey=AAA&path=4.2.3.2.4&cptr=4K7mX9 → still generic (stub returns null) — confirms the class branch is wired but inactive.
  • Load /scan-error?reason=invalid&details=unverified → genuineness messaging renders.
  • Take a screenshot of /o/:oid to share as proof.

  • Step 5: Commit
cd Plings-Web
git add src/App.tsx
git commit -m "feat(web): register /o/:oid, /welcome and /scan-error routes"

Task 11: Documentation updates (ships with the code)

Per CLAUDE.md, docs update in the same change. Update the affected docs (all under Plings-Web/docs/, so one commit on dev).

Files:

  • Modify: docs/core-systems/s-plings-io/url-structure.md
  • Modify: docs/database/neo4j-core-schema.md
  • Modify: docs/frontend/routing.md
  • Modify: docs/api/resolve-identifier-endpoint.md (and add publicObject/publicClass notes)

  • Step 1: url-structure.md — change the “Service-Specific Routing” table so all known statuses route to plings.io/o/{oid} for v1 (note the market/rent/lend subdomains are future); add sid to the “Standard Redirect Parameters” table; document reason=unverified in Error Handling; confirm cpcptr pass-through to /welcome. Add an **Updated**: timestamp line (use date "+%a %d %b %Y %H:%M:%S %Z").

  • Step 2: neo4j-core-schema.md — note that :ScanEvent persistence is now implemented (Phase 1: timestamp, identifier_type, ip_hash, object_id; SCANNED relationship); latitude/ longitude land in Phase 2. Add an **Updated**: line.

  • Step 3: routing.md — add /o/:oid, /welcome, /scan-error to the public-route inventory; note they are unauthenticated. Add an **Updated**: line.

  • Step 4: API docs — document publicObject(id) and publicClass(classPointer) (stub) and the new sid parameter / /o/:oid convergence in resolve-identifier-endpoint.md.

  • Step 5: Commit
cd Plings-Web
git add docs/core-systems/s-plings-io/url-structure.md docs/database/neo4j-core-schema.md docs/frontend/routing.md docs/api/resolve-identifier-endpoint.md
git commit -m "docs: scan landing page — routing convergence, sid, :ScanEvent, public queries"

Definition of done (Phase 1)

  • cd Plings-API && poetry run pytest tests/test_scan_persistence.py tests/test_public_object.py tests/test_public_class_stub.py tests/test_genuineness_stub.py tests/test_resolve_identifier_auth.py -v → all pass.
  • cd Plings-Gateway && npm test → all pass.
  • cd Plings-Web && npm run type-check && npm run build → succeed; the four URLs render correctly in the dev preview.
  • Docs updated in the same change.
  • FINAL: Use quality-control-enforcer agent to validate implementation (per CLAUDE.md, mandatory for GraphQL resolver, scan-routing, and DB changes).