← back to Wholivedthere
PLAN-geo-archival.md
245 lines
# PLAN — Geolocation + Archival + User-Submitted (wholivedthere first)
**Status:** drafted 2026-05-01 · awaiting Steve sign-off before any code lands
**Scope:** add three things to the WhoLivedThere ecosystem, starting with wholivedthere.com:
1. Geolocation feature — "Find places near me" (button-gated, NOT onload)
2. Mobile-first responsive layout (WCAG 2.2 AA baseline)
3. Archival data ingestion + user-submitted contributions (Reddit/Nextdoor explicitly OUT)
**Dropped from original ask:** Reddit + Nextdoor scraping. See `feedback_no_social_scraping_address_history.md`.
**Codebase:** pastdoor (`/root/public-projects/pastdoor` on Kamatera, pm2 :9821). One Next.js app host-routes all 3 WhoLivedThere domains. `~/Projects/stayclaim/` is the local mirror but has DIVERGED — every work session starts with `rsync remote → /tmp/pastdoor-work`.
---
## 0. Pre-flight (every session)
```bash
# Sync remote pastdoor into a fresh local working dir
rsync -avz --delete \
--exclude='.next' --exclude='node_modules' --exclude='.git' \
root@45.61.58.125:/root/public-projects/pastdoor/ \
/tmp/pastdoor-work/
cd /tmp/pastdoor-work
```
Local `~/Projects/stayclaim/` is read-only reference; never push from there.
---
## 1. Surface order
| # | Surface | Why first/later |
| - | -------------------- | ----------------------------------------------------------------------------------------------------- |
| 1 | wholivedthere.com | Archive-history brand fit. Cleanest narrative: "you're at X → here's what archives say about it" |
| 2 | bubbesblock.com | Stricter PII posture (Jewish-genealogy framing). Inherits geo from wholivedthere with stricter consent |
| 3 | claimmyaddress.com | Identity provider — geolocation lands AFTER Site Factory's claim flow has ID-proof guardrail |
Per-surface theming reads from `lib/site.ts::currentSurface()` (already exists). Geolocation policy must branch on surface — see §6.
---
## 2. New API routes (on pastdoor — collision-safe)
Per `feedback_pastdoor_route_collisions.md`, these prefixes are SAFE (won't be intercepted by nginx for Site Factory):
| Route | Method | Purpose |
| ---------------------------------- | ------ | -------------------------------------------------------------------- |
| `/api/geo/nearest` | POST | `{lat, lon}` → returns nearby parcel + nearest archive items (DPLA) |
| `/api/contributions` | POST | User-submitted content (text/photo) — auth-gated, status=PENDING |
| `/api/contributions/moderate` | GET | Returns pending queue (admin-gated) |
| `/api/contributions/:id/decision` | POST | `{action: approve|reject, reason}` (admin-gated) |
**Forbidden prefixes** (nginx routes them to Site Factory): `/api/auth/*`, `/api/checkout/*`, `/api/health/*`, `/api/webhook/*`, `/api/claim/*`.
**Identity / claim handoff:** geolocation auto-detect gives a button "Claim 123 Main St" → redirects to `https://claimmyaddress.com/api/claim/start?address=...` (Site Factory). pastdoor never handles claims locally.
---
## 3. Schema (CORRECTED 2026-05-01 after reading actual `~/Projects/stayclaim/db/`)
**Real existing tables (not what the master PLAN.md implied):**
- `listing` — properties; has `tier` (free|basic|pro), this is the **product** tier, NOT the source-quality tier
- `place_event` — append-only event ledger with `source_tier char(1) CHECK ('A','B','C','D')`, `source_label`, `source_url`, `source_id`, `confidence`, `public_visible`. THIS is the source-tier column to use.
- `entity_place_association` — also has source_tier
- `pd_film_image` — public-domain images only (rights='public-domain'); enum source ∈ wikimedia/archive_org/loc/nara
- `address_submission` — uses `contact_email` (no users table — auth lives at Site Factory)
- `claim_request`, `upgrade`, `entity`, `listing_override`, `la_parcel_cache`, `la_permit_cache`, `walk_of_fame`, etc.
**No `media_asset` or `users` table exists.** Auth is delegated to Site Factory (claimmyaddress.com); we mirror `address_submission`'s `contact_email` pattern for contributor identity.
**Migration written:** `~/Projects/stayclaim/db/migrations/019_user_contributions.sql` — additive only:
- `place_event` gets: `contributor_email`, `moderation_status` (PENDING|APPROVED|REJECTED, default APPROVED for backfill), `moderation_reason`, `moderated_at`, `moderator_email`
- New table `user_image` — user-submitted photos (tier-locked to 'C', distinct from pd_film_image)
- Wrapped in `BEGIN/COMMIT`; explicit ROLLBACK plan in the migration footer
**Tier conventions (locked):**
- DPLA / LoC / Calisphere / LAPL / USC DigLib / Wikimedia: tier **A** if direct gov/library catalog, **B** if aggregator-mediated
- IA fulltext / OCR: tier **B**
- User-submitted (after approve): tier **C**
- Inferred / pattern-matched: tier **D**
Migration applies on Kamatera `stayclaim` DB. **ALTER on local first** doesn't apply here — `feedback_pg_alter_table_order.md` is for `dw_unified` logical replication; stayclaim is a separate non-replicated DB on Kamatera. So: snapshot Kamatera DB → apply migration → verify → done.
---
## 4. Geolocation UX (button-gated, NOT onload)
```tsx
// /app/components/FindNearby.tsx (new)
<button onClick={async () => {
if (!navigator.geolocation) return setError('Geolocation not supported');
navigator.geolocation.getCurrentPosition(
pos => fetchNearby(pos.coords.latitude, pos.coords.longitude),
err => setError('We need location access to show nearby archives.'),
{ enableHighAccuracy: false, timeout: 10000, maximumAge: 600000 }
);
}}>
Find places near me
</button>
```
**Storage:** ephemeral. Coords go to `/api/geo/nearest`, get processed, response returned. **Server logs do NOT persist lat/lon** — only the resolved place_id / parcel_id (CCPA/CPRA precise-geolocation = sensitive PI). If we ever want to persist, it's an opt-in checkbox + retention policy (out of scope for v1).
**PWA path:** later. Once wholivedthere is a PWA with returning users, persisted permission means the button can auto-fire on first interaction without a fresh prompt. v1 = button only.
---
## 5. Mobile-first + WCAG 2.2 AA
Tailwind base in pastdoor already uses mobile-first breakpoints. Pass to do:
- All new buttons / links: min `h-11 w-11` (44×44 px touch target)
- Address-search input: `aria-label="Search address"`, visible label, 16px+ font (prevents iOS zoom)
- `<FindNearby>` button: `aria-busy` when fetching, screen-reader-only "Locating you, please wait"
- Color contrast: Gucci palette already meets AA on white; verify red CTAs (`#c9292e`) on cream (`#f3ebd9`) — 4.6:1, OK
- `<FilmsGrid>` images: `loading="lazy"`, real `alt` from `pd_film_image.title`
- Tap-to-zoom on archive image cards (one-finger, no pinch dependency)
**Test:** `mcp__chrome-devtools__lighthouse_audit` after each surface ships, accessibility score ≥95.
---
## 6. Per-surface PII branching
```ts
// /lib/privacy.ts (new)
type Surface = 'flagship' | 'claim' | 'community';
export const geoPolicy: Record<Surface, GeoPolicy> = {
flagship: { allowAnonymous: true, persistCoords: false, ethnicity: 'never' },
claim: { allowAnonymous: false, persistCoords: false, ethnicity: 'never' }, // requires SF claim auth
community: { allowAnonymous: false, persistCoords: false, ethnicity: 'never' }, // bubbesblock — strictest
};
```
bubbesblock: even with auth, no auto-detect of names/cultures from coords or address. The Jewish-genealogy framing means any inference of ethnicity = brand failure. Explicit user-attested only.
---
## 7. Archive ingestion (delegate to la-research-agent)
The `/api/geo/nearest` handler does **not** call DPLA/Calisphere/etc. directly. It delegates:
```ts
// /app/api/geo/nearest/route.ts
const records = await callLAResearchAgent({
address: resolved.formatted,
lat, lon,
radius_m: 200,
source_priority: ['lapl', 'usc_diglib', 'calisphere', 'dpla', 'ia', 'loc', 'wikimedia'],
tier_floor: 'B', // never serve tier C/D from auto-lookup — only user-submitted
});
```
**Why delegate:** `la-research-agent` already knows verified dataset IDs (avoids the `9yda-i4ya` mistake), enforces drill-down URL patterns per `feedback_drill_down_archive_urls.md`, and pairs Wikipedia citations with primary archive URLs.
**Agent invocation:** spawn via existing `mcp-la-records` MCP server at `~/Projects/wholivedthere/mcp-la-records/`. Single tool: `lookup_address(query)`.
---
## 8. User-submitted contributions (moderation queue)
**Submit flow:**
1. Logged-in user (auth via Site Factory `/api/auth/*`) lands on `/address/[slug]`
2. "Add a memory or photo" CTA → `<ContributeForm>` (text + optional image upload to CF R2)
3. POST `/api/contributions` with `{address_id, kind: 'memory'|'photo', body, media_url?}`
4. Stored with `moderation_status=PENDING`, `source_tier='C'`, `contributor_user_id=req.user.id`
5. NOT shown publicly until approved
**Moderate flow:**
- `/admin/moderation` page (admin-only via Site Factory auth + role check)
- Lists PENDING with image preview + body + submitter
- Approve → `moderation_status=APPROVED`, becomes visible
- Reject → `moderation_status=REJECTED`, optional reason emailed via George
**Citation discipline (per C6):**
- User-submitted = always `source_label="Submitted by [first_name + last_initial]"`
- If user references an external source (Reddit thread, news article), URL goes in `source_url` — but the contribution itself is tier C, not the linked source
- Wholesale-mirroring not allowed; a contribution that's just "copied this Reddit post" gets rejected with reason
---
## 9. Deploy + verification
Per `feedback_nginx_proxy_port_check.md`:
1. Deploy: `rsync /tmp/pastdoor-work/ root@45.61.58.125:/root/public-projects/pastdoor/` then `ssh ... "cd /root/public-projects/pastdoor && npm run build && pm2 restart pastdoor"`
2. Verify nginx unchanged: `ssh root@kamatera "grep proxy_pass /etc/nginx/sites-enabled/{wholivedthere,bubbesblock,claimmyaddress}.com.conf"` — confirm path-routed split is intact, no single `location /` collapse
3. Public-URL smoke tests (against the actual domain, not localhost):
- `curl -s https://wholivedthere.com/api/geo/nearest -X POST -d '{"lat":34.0,"lon":-118.4}' -H 'Content-Type: application/json'` → 200 with JSON
- `curl -s https://wholivedthere.com/_next/static/css/*.css | sha256sum` — different from bubbesblock + claimmyaddress (proves no co-tenant CSS collision)
- Lighthouse mobile a11y ≥95
4. If anything fails, **don't** patch in prod — pull back to /tmp/pastdoor-work, fix, redeploy
---
## 10. Schedule for the actual build
| Slice | Deliverable | ETA | Verification |
| ----- | ---------------------------------------------- | ------------ | ------------------------------------- |
| 1 | Schema migration + tier columns | 30 min | psql describes columns; existing rows backfilled to A/B |
| 2 | `/api/geo/nearest` + la-research-agent wiring | 2 hr | curl → JSON with at least 3 archive items for a known LA address |
| 3 | `<FindNearby>` button + nearby panel UI | 90 min | Browser test on iPhone-sized viewport, button works, results render |
| 4 | `<ContributeForm>` + `/api/contributions` POST | 2 hr | Submit a memory, see PENDING in DB, no public visibility |
| 5 | `/admin/moderation` page | 90 min | Approve a pending submission, verify it appears on /address/[slug] |
| 6 | Mobile-first CSS pass + WCAG audit fixes | 2 hr | Lighthouse mobile a11y ≥95 on /address, /, /films |
| 7 | Deploy to wholivedthere (only) | 30 min | All §9 verification steps pass |
| 8 | bubbesblock + claimmyaddress rollout w/ surface gates | 90 min | Per-surface policy from §6 honored |
**Total:** ~11 hr of focused work over 2-3 sessions.
---
## 11. Open questions / decisions needed during build
These don't block PLAN sign-off but need answers before the relevant slice:
1. **Slice 1:** Snapshot the Kamatera `stayclaim` DB before migration? (Recommend yes — `pg_dump` to /root/db-snapshots/ before ALTER.)
2. **Slice 2:** la-research-agent's response time on cold queries (>5s seen in past). Cache hits in Redis or PG? Or just pass-through and accept latency?
3. **Slice 4:** Image uploads to CF R2 — does pastdoor have R2 creds in env? If not, defer image submissions to v1.1 and accept text-only contributions in v1.
4. **Slice 5:** Admin role check — pastdoor has its own `/admin` UI? Or does admin moderation live on Site Factory under `/api/admin/*`? (Lean: pastdoor `/admin/moderation` with auth check against `claimmyaddress.com` session token via existing OAuth-style delegation per project_wholivedthere_ecosystem.md.)
5. **Slice 8:** When surface=community (bubbesblock), should the Find-Nearby button show DIFFERENT archive sources? (E.g., LAPL Jewish Heritage collection prioritized? Or same source list with surface-aware UI copy?)
---
## 12. Out of scope (v1)
- Reddit / Nextdoor ingestion (decided 2026-05-01)
- Server-side persistence of user GPS coords
- PWA installability + persisted geolocation permission
- Image moderation by AI (manual queue only for v1)
- Auto-translation of contributions
- Email notifications to contributors on approve/reject (use George later)
- Anything on claimmyaddress.com claim flow itself (Site Factory's domain, separate from this work)
---
## 13. Sign-off checklist
Steve confirms before slice 1 starts:
- [ ] PLAN scope is right (3 features, drop social, wholivedthere first)
- [ ] Tier conventions match existing schema philosophy
- [ ] Per-surface PII branching is correct (esp. bubbesblock strictest)
- [ ] Q1 (DB snapshot before migration): yes
- [ ] Q3 (image uploads in v1 or v1.1): TBD
- [ ] Total ETA budget OK (~11 hr across 2-3 sessions)