Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .wayfinder/BUILD-PROMPT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# GOAL: Ship `events rsvp` (+ `explore rsvp` alias) with questionnaire support in partiful-cli

## Context
Repo: `~/repos/partiful-cli` (Commander.js, plain JS, no build step, no TypeScript). One file per command group in `src/commands/`; ALL API access goes through `src/lib/`. Tests: `npm test` (vitest). Install trap: the global `partiful` binary is a COPY, not a symlink, so source edits do NOT take effect until you rerun `npm install -g .`.

Read these BEFORE writing code:
- `AGENTS.md` (repo root) for conventions and boundaries.
- `docs/explore-command-design.md` (design note).
- `.wayfinder/map.md` and all `.wayfinder/tickets/*.md` (especially 02, 04, 05, 07).
- Load the `partiful` skill, `hermes-shared-chrome-cdp` skill, and `cli-api-recon` skill.

## What is already decided (do NOT relitigate)
- **Self-RSVP endpoint = `addGuest`** (confirmed, captured live). Params: `{eventId, rsvp:{name, count, plusOnes[], message, emailInvitationId, status, guestId, timezone, password}}`. Statuses `GOING|MAYBE|DECLINED` all go through the ONE call via the `status` field. First RSVP sends `guestId:null` (server creates the record); edits send the returned `guestId` back to update.
- **Interested = `markEventInterest`** `{eventId, interested:bool, source?}`.
- **`updateGuestStatus` is HOST-ONLY** (403 on your own record). Do NOT use it for self-RSVP.
- **Auth:** reuse `src/lib/http.js` (Firebase Bearer, auto-refresh). A raw unauthed fetch 401s. Do NOT hand-roll auth.
- **Same endpoint for all events** (invited, discovered, self-owned). Ownership only gates host-management calls.
- **Command naming (DECIDED by Kaleb 2026-07-24):**
- CANONICAL: `events rsvp <id> [--status going|maybe|declined] [--plus-one NAME] [--count N] [--message TXT] [-y] [--dry-run]` -> `addGuest`
- ALIAS: `explore rsvp <id>` -> thin forward to the SAME handler.
- Same alias pattern for `events interested` / `explore interested <id> [--remove]` -> `markEventInterest`.
- Single shared handler; `explore *` verbs just forward to the `events *` implementation.

## The ONE open unknown to resolve first (recon)
How custom questionnaire answers ride inside the `addGuest` payload. Some hosts require Q&A before an RSVP submits (`getLastQuestionnaireAnswers` is the tell). This affects regular invited events too, not just discovery. Do NOT ship without handling it.

### Recon method (ticket 07 rig)
1. Create a THROWAWAY self-owned Partiful event WITH a required custom question, via the web app (the CLI has no questionnaire flag). Keep it private/unlisted. (Ask Kaleb to create it and hand you the `/e/{id}` link if you cannot create questions programmatically.)
2. Attach to Kaleb's logged-in local Chrome via CDP on port 9222 (`hermes-shared-chrome-cdp` skill). The remote/cloud browser is NOT logged into Partiful; the local one is.
3. In the CDP tab, hook `window.fetch` + `XMLHttpRequest` to log all `api.partiful.com` calls, THEN navigate to the event `/e/{id}`.
4. Click RSVP, answer the question, choose Going, Continue. Capture the `addGuest` request body.
5. Diff against the known clean payload to find where answers live (likely a new field inside `rsvp`, e.g. `answers[]` / `questionnaireAnswers`). Note the shape (question id vs text, answer format).
6. DELETE the test event. Zero residue.

## Build (after recon)
1. Implement a single shared RSVP handler in `src/commands/` reusing `src/lib/http.js`.
2. Read-before-write: the CLI is stateless, so the handler must call `getCurrentGuest {eventId}` first to decide create (`guestId:null`) vs update (pass existing `guestId`).
3. Wire `events rsvp` (canonical) and `explore rsvp` (alias -> same handler). Same for `interested`.
4. Bake in questionnaire support per the recon findings. If an event requires a questionnaire and the user did not supply answers, fail clearly (do not silently submit).
5. Flags: `--status` (default `going`), `--plus-one` (repeatable), `--count`, `--message`, `--password` (field already in payload), `-y/--yes`, `--dry-run`.
6. Confirmation gate on writes by default (writing to a guest list); `-y` skips for agent flows. `--dry-run` previews the payload without sending.
7. Refuse ticketed/paid events cleanly (Stripe wall) and point the user to the app.

## Hard rules
- **NO em dashes anywhere** in user-facing output or event copy the CLI writes (Kaleb hard rule). Use colons/commas.
- Never expose phone numbers or Partiful user IDs in user-facing output.
- Ask before sending text blasts, cancelling events, or bulk ops.
- Do not hardcode auth tokens in source.

## Definition of done
- `events rsvp` and `explore rsvp` both work end-to-end against a real event (verify with `--dry-run` first, then a live RSVP on a self-owned event, then revert/delete).
- Questionnaire event RSVP works (verified on the recon test event before deletion).
- `npm test` passes; add unit tests for the handler (mock `src/lib/http.js`).
- `npm install -g .` rerun so the global binary reflects changes.
- Update the `partiful` skill + repo README with the new commands.
- Update the Todoist task `id:6h6Pvxgw2mJf466G` to reflect shipped state (or leave a comment with the final command surface).
66 changes: 66 additions & 0 deletions .wayfinder/map.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<!-- wayfinder:map -->
# Map: Partiful `explore` — trending discovery + RSVP

## Destination

A shipped `partiful explore` command surfacing Partiful's public trending/discovery
events, AND the ability to **RSVP** (going) or mark **interested** on a discovered
event you were never invited to. "Shipped" = command shape, output contract,
build-id handling, and the discover write-path (RSVP/interested) all decided and
implemented in `~/repos/partiful-cli`, with skill/README docs updated.

This is an **execution-carrying** map: it ends in merged code, not just a spec.

## Notes

- Domain: reverse-engineered Partiful internal API. No official docs.
- Repo: `~/repos/partiful-cli`. Commander.js, plain JS, no build step. One file per
command group in `src/commands/`; ALL API access goes through `src/lib/`.
- Install trap: global `partiful` is a COPY, not a symlink. Source edits don't take
effect until `npm install -g .` reruns. (per partiful skill + AGENTS.md)
- Tests: `npm test` (vitest). Integration tests hit real API + need auth.
- Skills every session should consult: `partiful` (skill), `cli-api-recon`,
`cli-architect`. Repo `AGENTS.md`.
- Auth is ASSUMED (see Decisions). Reuse `src/lib/http.js` token flow; no anon mode.
- No em dashes in any user-facing event copy this CLI writes (Kaleb hard rule).

## Decisions so far

- [Auth model: always logged in](#) — `explore` reuses the existing Firebase-token
flow via `src/lib/http.js`. No anonymous/unauthed mode. The CLI's contract is that
the user is logged in; discovery read endpoints happen to be public, but we don't
build a separate anon path.
- [Discovery is not read-only](#) — RSVP (going) and "interested" on a discovered
event are in scope for v1, not deferred.
- [BUILD-ID: use stable api.partiful.com, no build id](.wayfinder/tickets/01-build-id-recon.md)
— `POST /getDiscoverFeed` (paginated feed) + `POST /getDiscoverSections` (trending
carousels + tags), Bearer auth, Firebase `{"data":{params,paging}}` envelope. Cursor
pagination via `result.paging.nextCursor`. No rotating build id needed; reuse
`src/lib/http.js`. `/_next/data/{buildId}` is the fallback (scrape buildId from
`__NEXT_DATA__`).
- [TAG-FILTER: server-side via tagId](.wayfinder/tickets/03-tag-filter-recon.md) —
`--tag` maps directly to the `tagId` param; verified filtering (NYC: HOME=20,
MUSIC=15, FOOD=4). Valid tags from `getDiscoverSections` `.tags[]`.
- [RSVP-ENDPOINT: two verbs, not one flag](.wayfinder/tickets/02-rsvp-endpoint-recon.md)
— INTERESTED and GOING use different endpoints, so `explore interested` and
`explore rsvp` are separate verbs. **INTERESTED fully solved**: `markEventInterest`
`{eventId, interested:bool, source?}` — true creates an INTERESTED guest record,
false removes it (verified live + reverted). `getCurrentGuest {eventId}` reads
state. **GOING SOLVED (2026-07-17)**: the self-RSVP mutation is `addGuest`
`{eventId, rsvp:{name,count,plusOnes[],message,status,guestId,timezone,password,...}}`.
First RSVP `guestId:null` (creates); edits pass returned `guestId`. GOING / MAYBE /
DECLINED all via the `status` field. Captured live from OpenClaw's logged-in Chrome
(CDP :9222); verified GOING then reverted DECLINED. Ticket 07 CLOSED.

## Not yet specified

- Caching strategy is now MOOT for the build id (stable API used). Any caching is a
minor perf choice deferred to IMPLEMENT (e.g. cache the tag list per region).
- Output columns for the human `--format table` view (which event fields matter).
Graduates once COMMAND-SHAPE is decided.

## Out of scope

- Event detail enrichment via `getDiscoverEventItemDecorators` (guest-count badges).
Nice-to-have overlay, not required to browse or RSVP. Revisit as a later effort.
- Non-US regions beyond what the region-slug endpoint already returns for free.
62 changes: 62 additions & 0 deletions .wayfinder/tickets/01-build-id-recon.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<!-- wayfinder:research -->
# BUILD-ID: how to resolve the rotating Next.js build id

Labels: wayfinder:research
Blocked by: (none — frontier)
Assignee: hermes
Status: closed

## Question

The discovery data endpoints are `/_next/data/{BUILD}/explore.json` and
`/_next/data/{BUILD}/explore/{region}.json?region={slug}`, where `{BUILD}` is the
Next.js `buildId` that rotates on every Partiful deploy (observed:
`A1rxlYfFYHBWL3Uop4ELL`). A hardcoded build id will 404 after the next deploy.

Determine the resolution strategy:
- Can the build id be scraped reliably from the `/explore` HTML
(`__NEXT_DATA__.buildId` or `/_next/static/{buildId}/_buildManifest.js`)?
- Is there a stable `api.partiful.com` endpoint that returns the same trending
data WITHOUT a build id (recon saw `getDiscoverEventItemDecorators`; is there a
`getDiscoverFeed` / `getTrendingEvents` sibling)? Probe common names.
- Fallback ordering and failure behavior.

Output: markdown summary in this ticket's answer — chosen strategy + the exact
request(s), with status codes. Feeds the caching decision (Not yet specified).

---

## Resolution (closed)

**Strategy chosen: hit the stable `api.partiful.com` endpoints directly. NO build id needed.**

The `/_next/data/{BUILD}/...` path works but requires the rotating buildId. The web app's own data layer calls two stable Firebase-callable endpoints that the CLI can use directly through the existing `src/lib/http.js` authed client. The build id is fully avoidable.

### Endpoints (POST, Bearer auth required, Firebase-callable `{data:{...}}` envelope)

**`POST https://api.partiful.com/getDiscoverFeed`** — the paginated event feed.
```json
{"data":{"params":{"region":"NYC","tagId":"DISCOVER_HOME","allowedFeedPresentationStyles":["rows"]},"paging":{"maxResults":100}}}
```
Response: `result.data.items[]` (each `{id,type,event:{...}}`), `result.paging.nextCursor`.

**`POST https://api.partiful.com/getDiscoverSections`** — trending carousels + tag list.
```json
{"data":{"params":{"region":"NYC","tagId":"DISCOVER_HOME","allowedSectionPresentationStyles":["carousel-small","rows"],"locale":"en"},"paging":{"maxResults":100}}}
```
Response: `result.data.sections[]` (trending carousels), `result.data.tags[]` (category list).

Also exists: `getDiscoverSection` (singular, `{params}` only) for one section.

### Key facts
- **Envelope is `{"data":{...}}`** (Firebase callable convention). A bare `{params,paging}` returns 400; `{data:...}` returns 401/200. This is why raw recon 400'd.
- **Auth IS required** — 401 without a valid Bearer. Reuse `src/lib/http.js` (it already sends the token + refreshes). The 400s during recon were an unauthed/expired token, not a bad endpoint.
- **Token refresh:** CLI auto-refreshes on any authed call; a stale `auth.json` token 401s until refreshed.
- **Region values:** `NYC, LA, SF, BOS, DC, CHI, LON, MIA, ATX` (uppercase in API params; lowercase slugs `nyc/la/...` only for the `/_next/data` web-page path).
- **Pagination = cursor.** `result.paging.nextCursor` → pass back as `paging.cursor` (or `paging.afterCursor`; confirm exact key in IMPLEMENT). `pageResultCount` also returned.

### Bonus — resolves TAG-FILTER ticket
`tagId` filters **server-side**: NYC DISCOVER_HOME=20, MUSIC=15, FOOD=4 items. `--tag` maps directly to the `tagId` param. No client-side filtering needed. TAG-FILTER can be closed as answered-by-BUILD-ID.

### Fallback
If these endpoints ever change, the `/_next/data/{buildId}/explore/{slug}.json?region={slug}` path still works; scrape `buildId` from `__NEXT_DATA__` on the `/explore` HTML (verified present).
90 changes: 90 additions & 0 deletions .wayfinder/tickets/02-rsvp-endpoint-recon.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<!-- wayfinder:research -->
# RSVP-ENDPOINT: how to RSVP / mark interested on a discovered event

Labels: wayfinder:research
Blocked by: (none — frontier)
Assignee: hermes
Status: closed

## Question

RSVP-ing to a DISCOVERED event is different from `guests invite` — the user was
never invited; they're crashing a public event. Find the API call the web
`/explore` and `/e/{id}` pages fire when a logged-in user clicks "Going" or
"Interested" on a public event.

Method: attach to the browser logged in as Kaleb, open a discovered public event,
capture the XHR/fetch on the RSVP + interested buttons (via performance entries /
CDP network). Identify:
- Endpoint URL(s) on `api.partiful.com` (e.g. `setRsvp` / `rsvpToEvent` /
`setGuestStatus` / `expressInterest`).
- Request body shape (eventId, status enum — GOING / INTERESTED / MAYBE?).
- Whether "interested" is a distinct status value on the same endpoint or a
separate endpoint.
- Auth header used (should be the same Firebase token `src/lib/http.js` sends).
- Success + error response shapes.

Do NOT actually RSVP to a stranger's event during recon unless unavoidable; if a
live write is needed, use a throwaway/self-owned event and clean up.

Output: markdown answer — endpoint(s), body, status enum, auth. Resolves the
"one --status flag vs two verbs" fog item.

---

## Resolution (closed — partially, with a follow-up)

All endpoints are Firebase-callable: `POST https://api.partiful.com/<name>`, Bearer
auth (reuse `src/lib/http.js`), body `{"data":{"params":{...}}}`, response
`{"result":{"data":{...}}}`.

### INTERESTED — fully solved and verified end-to-end
**`markEventInterest`** — params `{eventId, interested: bool, source?}`.
- `interested:true` -> `{interested:true, success:true}`; **creates a guest record**
with `status:"INTERESTED"`.
- `interested:false` -> `{interested:false, previousStatus:"INTERESTED", success:true}`;
**removes** the guest record (verified: currentGuest -> NONE after).
- `source` is OPTIONAL (200 with it omitted). Web sends an enum value (`DISCOVER`);
any string or absence works. Recommend sending `"DISCOVER"`.
- Read current state with **`getCurrentGuest`** `{eventId}` ->
`result.data.currentGuest.{id,status}` (or null).
- Verified live on 2 discovered events (5R73..., UHjP...), then reverted, no residue.

### GOING (RSVP) — SOLVED 2026-07-17: the mutation is `addGuest`
Captured live from OpenClaw's logged-in local Chrome (CDP :9222) — see ticket 07.
**`addGuest`** — params `{eventId, rsvp:{name, count, plusOnes[], message, emailInvitationId,
status, guestId, timezone, password}}`. First RSVP sends `guestId:null` (server creates
record); edits send the returned `guestId` to update. Statuses `GOING` / `MAYBE` / `DECLINED`
ALL go through this one call via the `status` field. Same Bearer auth (`src/lib/http.js`);
a raw unauthed fetch 401s. Verified end-to-end: RSVP'd GOING then reverted to DECLINED, clean.

### (superseded) earlier dead-ends on the GOING path
- **`updateGuestStatus`** `{eventId, guestId, guestStatus, rsvpReason?, newGuestName?}`
is **HOST-ONLY**. Returns 403 `PERMISSION_DENIED "User is not a host of this event
and is not an admin"` even for the caller's OWN guest record, even on an event the
caller is legitimately invited to (tested idempotent MAYBE->MAYBE). So it is the
host's guest-management tool, NOT the self-RSVP path. Do not use it for `explore rsvp`.
- **`addInvitedGuestsAsGuest`** `{eventId, userIdsToInvite[], phoneContactsToInvite[],
invitationMessage?}` is for inviting mutuals, and 400'd `FAILED_PRECONDITION
"Guests are not allowed to invite mutuals to this event"` when self-targeted. Not it.
- Probed 12 plausible names (respondToEvent, rsvpToEvent, setMyRsvp, joinEvent,
selfRsvp, addSelfAsGuest, ...) -> all 404. The real self-RSVP mutation is
**lazy-loaded** in a dynamic chunk not present in the initial `/e/[event]` bundle,
so static grep of the 27 eager chunks did not surface it.

### Status enum (wire values, from public getMyRsvps / getCurrentGuest)
`GOING . MAYBE . DECLINED . INTERESTED . WAITLIST . APPROVED . SENT` (uppercase literals).

### Follow-up ticket created: 07-rsvp-going-live-capture
The GOING mutation must be captured from a **logged-in browser**: open a public
discovered event, click "RSVP -> Going", record the XHR to `api.partiful.com`
(method name + `params` shape). The remote automation browser is NOT logged into
Partiful, so this needs Kaleb's local logged-in browser (CDP) or a manual devtools
capture. Blocks the GOING half of IMPLEMENT.

### Recommendation for COMMAND-SHAPE
Both write paths now proven. Ship two verbs (not a shared `--status` flag, since
interested and RSVP use different endpoints):
- **`explore interested <eventId>` (+ `--remove`)** — `markEventInterest`.
- **`explore rsvp <eventId> [--status going|maybe|declined]`** — `addGuest`.
Both reuse `src/lib/http.js` auth. Ticket 07 fully closed; nothing gating IMPLEMENT.
39 changes: 39 additions & 0 deletions .wayfinder/tickets/03-tag-filter-recon.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<!-- wayfinder:research -->
# TAG-FILTER: is category/tag filtering server-side or client-side?

Labels: wayfinder:research
Blocked by: (none — frontier)
Assignee: (unclaimed)
Status: closed

## Question

The region feed returns a `tags` array (DISCOVER_HOME, MUSIC, COMMUNITY, ARTS,
FITNESS, FOOD, + neighborhood tags). In recon, `?tag=MUSIC` and `?tagId=MUSIC`
did NOT change `selectedTagId` (stayed DISCOVER_HOME) or the feed — filtering
appears client-side or uses an unknown param.

Determine:
- Grep the `explore/[region].js` Next chunk for how a tag click changes the feed
(does it re-fetch with a param, or filter `feedItems` in-memory?).
- If server-side: the exact param name + value format.
- If client-side: confirm the CLI must filter `feedItems` locally by each item's
`tags` field.

Output: answer states server-side (with param) vs client-side (filter locally).
Resolves the `--tag` behavior fog item. If this proves expensive, v1 may ship
region+trending only and defer `--tag` — flag that in the answer.

---

## Resolution (closed — answered by BUILD-ID recon)

**Server-side.** The stable `getDiscoverFeed` / `getDiscoverSections` endpoints take a
`tagId` param that filters server-side. Verified on region=NYC:
DISCOVER_HOME=20 items, MUSIC=15, FOOD=4.

CLI `--tag` maps directly to `tagId`. Valid values come from the `tags[]` array in
`getDiscoverSections` (DISCOVER_HOME, MUSIC, COMMUNITY, ARTS, FITNESS, FOOD, plus
region-specific neighborhood tags like NYC_BROOKLYN). No client-side filtering needed.

See ticket 01 (BUILD-ID) resolution for full endpoint contract.
Loading