Public Scan Landing Page — Phase 1 Implementation Plan
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 thedevbranch, Plings-API and Plings-Gateway targetmain. Stage explicit paths — nevergit 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-worktreesskill 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/buildand 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; Webcd 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:ScanEventpersistence; returnsid; 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.py—publicObject(id)query +publicClass(classPointer)stub query. - Modify
app/graphql.py— add SDL types (PublicObject,PublicObjectClass,PublicOwner,PublicLastSeen,PublicClass) +Queryfields; register the newQueryTypeinmake_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/:oiddestination +oid/sidpass-through in the backend-integration block. (No production-code change: the backend supplies destination +sidinrouting.parameters, which the Gateway already forwards.)
Plings-Web
- Create
src/graphql/public.ts—PUBLIC_OBJECT,PUBLIC_CLASSdocuments + TS types. - Create
src/lib/openInApp.ts— deep-link helper with store fallback. - Create
src/pages/scan/ObjectLanding.tsx—/o/:oidstatus-aware page. - Create
src/pages/scan/Welcome.tsx—/welcomeonboarding (+ class-aware branch). - Create
src/pages/scan/ScanError.tsx—/scan-errorgenuineness 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
sidto 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"
Task 7: Deep-link helper (Plings-Web)
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 thepublicObjectquery 200. - Load
/welcome?ikey=AAA&path=4.2.3.2.4→ onboarding renders;publicClassis skipped (nocptr). - 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/:oidto 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 addpublicObject/publicClassnotes) -
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); addsidto the “Standard Redirect Parameters” table; documentreason=unverifiedin Error Handling; confirmcp→cptrpass-through to/welcome. Add an**Updated**:timestamp line (usedate "+%a %d %b %Y %H:%M:%S %Z"). -
Step 2: neo4j-core-schema.md — note that
:ScanEventpersistence is now implemented (Phase 1: timestamp, identifier_type, ip_hash, object_id;SCANNEDrelationship); latitude/ longitude land in Phase 2. Add an**Updated**:line. -
Step 3: routing.md — add
/o/:oid,/welcome,/scan-errorto the public-route inventory; note they are unauthenticated. Add an**Updated**:line. -
Step 4: API docs — document
publicObject(id)andpublicClass(classPointer)(stub) and the newsidparameter //o/:oidconvergence inresolve-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).