diff --git a/README.md b/README.md index ce04ca2..213135c 100644 --- a/README.md +++ b/README.md @@ -100,18 +100,26 @@ partiful events cancel ### `events rsvp` / `events interested` — RSVP to events ```bash -partiful events rsvp # RSVP going (default) -partiful events rsvp --status maybe # going | maybe | declined -partiful events rsvp --plus-one Maddie --plus-one Justin # bring guests -partiful events rsvp --name "Kaleb Cole" --message "Stoked!" +partiful events rsvp get # Read your RSVP and questionnaire answers +partiful events rsvp set # RSVP going (default) +partiful events rsvp set --status maybe # going | maybe | declined +partiful events rsvp set --plus-one Maddie --plus-one Justin # bring guests +partiful events rsvp set --name "Kaleb Cole" --message "Stoked!" +partiful events rsvp set --answer "=" # repeat per host question partiful events interested # mark interest partiful events interested --remove # remove interest ``` -RSVP does a read-before-write: it updates your existing guest record if you -already RSVP'd, otherwise creates one. Ticketed and questionnaire-gated events -are refused with a clear error (use the app for those). The same verbs are -available under `explore` (`partiful explore rsvp `) for the discovery flow. +`events rsvp get` reads your saved status, guest details, and questionnaire +answers without changing the RSVP. RSVP writes do a read-before-write: they update your existing guest record if you +already RSVP'd, otherwise create one. Ticketed events are refused with a clear +error (use the app to purchase a ticket). For host-questionnaire events, pass one +repeatable `--answer "="` per answer; required +answers are validated before submission. Plain `--dry-run` stays offline, while +`--answer ... --dry-run` performs read-only guest/event lookups so the preview can +validate and preserve live questionnaire state, then read the Firestore guest record +back to verify the saved status and answers. The same verbs are available under +`explore` (`partiful explore rsvp get `) for the discovery flow. ### `guests` — Manage event guests diff --git a/docs/explore-command-design.md b/docs/explore-command-design.md index 719015a..ddfe652 100644 --- a/docs/explore-command-design.md +++ b/docs/explore-command-design.md @@ -11,7 +11,7 @@ Wire callables are hidden — the CLI never says `addGuest`. | User verb | Hidden callable | |---|---| | `explore list` / `explore trending` / `explore regions` | `getDiscoverFeed`, `getDiscoverSections` | -| `explore rsvp` | `addGuest` | +| `explore rsvp set` | `addGuest` | | `explore interested` | `markEventInterest` | --- @@ -27,7 +27,8 @@ Subcommands: list Browse the discovery feed (default region: nyc) trending Trending carousels grouped by region regions List available regions + their tags - rsvp RSVP yourself to a public event (going/maybe/declined) + rsvp get Read your current RSVP and questionnaire answers + rsvp set RSVP yourself to a public event (going/maybe/declined) interested Mark yourself interested (softer than RSVP) ``` @@ -56,19 +57,24 @@ partiful explore regions [--format json|table] Lists region slugs + the tag list (id + friendly name) for --tag filtering. ``` -### `explore rsvp ` -``` -partiful explore rsvp [options] +### `explore rsvp set ` +```text +partiful explore rsvp set [options] - --status going | maybe | declined (default: going) + --status going | maybe | declined (preserves current status; + defaults to going for a new RSVP) --plus-one Add a named plus-one (repeatable) - --count Total headcount incl. yourself + plus-ones (default: 1) + --count Total headcount incl. yourself + plus-ones --message Public comment posted on the event page + --answer + Host-questionnaire answer by question ID or exact text + (repeatable) -y, --yes Skip the confirmation prompt (agent flows) - --dry-run Print the payload, don't write + --dry-run Print the payload, don't write. Plain previews stay offline; + previews with --answer read current guest and event state. - Refuses (exit 4, type unsupported_event) on ticketed / password-gated / - questionnaire events — use the Partiful app for those. + Refuses unsupported ticketed events. Supported host-questionnaire events + require complete valid answers and preserve omitted saved answers. ``` ### `explore interested ` @@ -124,7 +130,7 @@ partiful explore interested [options] } ``` -### `explore rsvp` (success) +### `explore rsvp set` (success) ```json { "eventId": "JKQD5kibarjDeBw4LN6W", @@ -136,7 +142,7 @@ partiful explore interested [options] } ``` -### `explore rsvp` (refused — ticketed) +### `explore rsvp set` (refused — ticketed) ```json { "status": "error", @@ -152,21 +158,22 @@ partiful explore interested [options] ## Behavior notes for IMPLEMENT -1. **Statelessness → read-before-write.** CLI won't have a `guestId` on a repeat - run. `explore rsvp` should call `getCurrentGuest {eventId}` first: if a record - exists, pass its `guestId` back to `addGuest` (update); else send - `guestId:null` (create). Same for `--status declined` (revert path). +1. **Statelessness → read-before-write.** Live `explore rsvp set` calls + `getCurrentGuest {eventId}` first: if a record exists, it preserves omitted RSVP + fields and passes its `guestId` back to `addGuest` (update); otherwise it sends + `guestId:null` (create). Answer-aware dry-runs perform the same reads so they can + validate and merge questionnaire answers; plain dry-runs remain offline. 2. **`addGuest` payload** (built by `wrapPayload`, hidden from user): `{eventId, rsvp:{name, count, plusOnes[], message, emailInvitationId:null, status, guestId, timezone, password:null}}`. `name` = `config.displayName`; `timezone` = config tz (default America/Los_Angeles). 3. **Status mapping:** CLI `going|maybe|declined` → wire `GOING|MAYBE|DECLINED`. -4. **Confirmation gate** (AGENTS.md destructive policy): `rsvp` + `interested` +4. **Confirmation gate** (AGENTS.md destructive policy): `rsvp set` + `interested` write to a real host's guest list → prompt unless `-y`. `--dry-run` prints payload + target endpoint, no write. -5. **Ticketed/password/questionnaire guard:** detect via `getEventInfo` / - `getEventRestrictions` before writing; refuse rather than create a broken - record. (These branches are UNTESTED against `addGuest` — see ticket 02/07.) +5. **Unsupported-event guard:** detect ticketed events via `getEventInfo` before + writing and refuse rather than create a broken record. Supported host + questionnaires are validated and submitted with the RSVP. 6. **File layout:** new `src/commands/explore.js`, `registerExploreCommands`, one command group, structured `{status, error:{code,type,message}}` errors, `jsonOutput`/`jsonError` like the rest. diff --git a/skills/partiful/references/rsvps-and-interest.md b/skills/partiful/references/rsvps-and-interest.md index 6436c20..989dde8 100644 --- a/skills/partiful/references/rsvps-and-interest.md +++ b/skills/partiful/references/rsvps-and-interest.md @@ -1,15 +1,23 @@ # RSVPs and Interest ```bash -partiful events rsvp --dry-run -partiful events rsvp --status going -partiful events rsvp --status going --plus-one "Alex Smith" -partiful events rsvp --status maybe --message "I may be late" -partiful events rsvp --status declined +partiful events rsvp get +partiful events rsvp set --dry-run +partiful events rsvp set --status going +partiful events rsvp set --status going --plus-one "Alex Smith" +partiful events rsvp set --status maybe --message "I may be late" +partiful events rsvp set --status declined partiful events interested partiful events interested --remove ``` -`explore rsvp` and `explore interested` are equivalent aliases. +`events rsvp get` reads your saved status and questionnaire answers without changing the RSVP. `explore rsvp get`, `explore rsvp set`, and `explore interested` are equivalent aliases under the discovery command group. -The current CLI cannot complete ticket purchases or host questionnaires. Questionnaire response fields are known internally, but `events rsvp` exposes no answer option and deliberately rejects questionnaire-gated events. Use Partiful directly for either flow. \ No newline at end of file +For questionnaire events, pass one repeatable answer per question. Keys may be the question ID or its exact text: + +```bash +partiful events rsvp set --answer "=" +partiful events rsvp set --answer "Dietary restrictions?=None" --answer "Song request?=Anything" +``` + +Required answers are validated before submission. Successful writes read the Firestore guest document back to verify the saved status and questionnaire answers. Plain `--dry-run` remains offline; combining `--answer` with `--dry-run` performs read-only guest and event lookups to validate the live questionnaire preview. Ticketed or paid events remain unsupported because the CLI cannot purchase tickets. \ No newline at end of file diff --git a/src/commands/rsvp.ts b/src/commands/rsvp.ts index f1635fc..70cecf4 100644 --- a/src/commands/rsvp.ts +++ b/src/commands/rsvp.ts @@ -2,7 +2,8 @@ * RSVP / interest commands: a single shared implementation wired under both the * canonical `events *` verbs and the `explore *` aliases. * - * events rsvp (alias: explore rsvp ) -> POST /addGuest + * events rsvp set (alias: explore rsvp set ) -> POST /addGuest + * events rsvp get (alias: explore rsvp get ) -> read current guest * events interested (alias: explore interested ) -> POST /markEventInterest * * The `explore *` verbs are thin forwards to the SAME handler; there is no @@ -12,7 +13,7 @@ import type { Command } from 'commander'; import { loadConfig, getValidToken, wrapPayload, decodeJwtPayload } from '../lib/auth.js'; -import { apiRequest } from '../lib/http.js'; +import { apiRequest, firestoreGetDocument } from '../lib/http.js'; import { jsonOutput, jsonError } from '../lib/output.js'; import { PartifulError } from '../lib/errors.js'; import { confirm } from '../lib/events.js'; @@ -22,7 +23,10 @@ import { buildInterestParams, isTicketedEvent, eventRequiresQuestionnaire, + buildQuestionnaireResponse, + parseQuestionnaireAnswers, resolveDisplayName, + type QuestionnaireResponse, type RsvpEvent, } from '../lib/rsvp.js'; @@ -42,6 +46,58 @@ function handleError(e: unknown): void { else jsonError((e as Error).message); } +interface FirestoreValue { + stringValue?: string; + integerValue?: string; + mapValue?: { fields?: Record }; +} + +interface FirestoreGuestDocument { + fields?: Record; +} + +function questionnaireResponseFromDocument(document: FirestoreGuestDocument): QuestionnaireResponse | null { + const responseFields = document.fields?.['questionnaireResponse']?.mapValue?.fields; + const versionValue = responseFields?.['questionnaireVersion']?.integerValue; + const answerFields = responseFields?.['answers']?.mapValue?.fields; + if (versionValue === undefined || answerFields === undefined) return null; + + const questionnaireVersion = Number.parseInt(versionValue, 10); + if (!Number.isFinite(questionnaireVersion)) return null; + + const answers: Record = {}; + for (const [questionId, value] of Object.entries(answerFields)) { + if (value.stringValue !== undefined) answers[questionId] = value.stringValue; + } + return { questionnaireVersion, answers }; +} + +function questionnaireResponsesEqual( + actual: QuestionnaireResponse | null, + expected: QuestionnaireResponse, +): boolean { + if (actual === null || actual.questionnaireVersion !== expected.questionnaireVersion) return false; + + const actualKeys = Object.keys(actual.answers); + const expectedKeys = Object.keys(expected.answers); + return actualKeys.length === expectedKeys.length + && expectedKeys.every((key) => Object.hasOwn(actual.answers, key) + && actual.answers[key] === expected.answers[key]); +} + +async function fetchGuestDocument( + token: string, + eventId: string, + guestId: string, + verbose: boolean | undefined, +): Promise { + return await firestoreGetDocument( + `events/${eventId}/guests/${guestId}`, + token, + verbose ?? false, + ) as FirestoreGuestDocument; +} + /** * Read the caller's own guest record (read-before-write). * @@ -53,7 +109,38 @@ function handleError(e: unknown): void { */ async function fetchCurrentGuest(config: ReturnType, token: string, eventId: string, verbose: boolean | undefined): Promise | null> { const res = await apiRequest('POST', '/getCurrentGuest', token, makePayload(config, { eventId }), verbose) as { result?: { data?: { currentGuest?: Record } } }; - return res.result?.data?.currentGuest ?? null; + const currentGuest = res.result?.data?.currentGuest ?? null; + const guestId = currentGuest?.['id']; + if (!currentGuest || typeof guestId !== 'string') return currentGuest; + + const document = await fetchGuestDocument(token, eventId, guestId, verbose); + const questionnaireResponse = questionnaireResponseFromDocument(document); + return questionnaireResponse + ? { ...currentGuest, questionnaireResponse } + : currentGuest; +} + +/** Read the caller's RSVP, including questionnaire answers, without mutating it. */ +export async function currentGuestAction(eventId: string, _opts: Record, cmd: Command): Promise { + const globalOpts = cmd.optsWithGlobals>(); + try { + const config = loadConfig(); + const token = await getValidToken(config); + const guest = await fetchCurrentGuest( + config, + token, + eventId, + globalOpts['verbose'] as boolean | undefined, + ); + + jsonOutput({ + eventId, + guest, + url: `https://partiful.com/e/${eventId}`, + }); + } catch (e) { + handleError(e); + } } /** @@ -75,7 +162,7 @@ function nameFromToken(token: string): string | null { } /** - * Shared RSVP handler. Backs `events rsvp` and `explore rsvp`. + * Shared RSVP handler. Backs `events rsvp set` and `explore rsvp set`. * Exported for unit testing of the orchestration branches. */ export async function rsvpAction(eventId: string, opts: Record, cmd: Command): Promise { @@ -84,17 +171,21 @@ export async function rsvpAction(eventId: string, opts: Record, const config = loadConfig(); const token = await getValidToken(config); - // Read-before-write: decide create (guestId:null) vs update. Skipped on - // dry-run so the preview is fully offline. + const answerPairs = (opts['answer'] as string[] | undefined) ?? []; + const suppliedAnswers = parseQuestionnaireAnswers(answerPairs); + + // Live writes and answer-aware previews use the same read-before-write state. + // Plain dry-runs remain offline for backward compatibility. let currentGuest: Record | null = null; let event: RsvpEvent | null = null; - if (!globalOpts['dryRun']) { + const needsRemoteState = !globalOpts['dryRun'] || answerPairs.length > 0; + if (needsRemoteState) { [currentGuest, event] = await Promise.all([ fetchCurrentGuest(config, token, eventId, globalOpts['verbose'] as boolean | undefined), fetchEvent(config, token, eventId, globalOpts['verbose'] as boolean | undefined), ]); - // Refuse ticketed/paid events cleanly (Stripe wall). + // Ticketed events cannot be represented by /addGuest, including previews. if (isTicketedEvent(event)) { jsonError( 'This is a ticketed or paid event. Self-RSVP is not supported here; use the Partiful app to purchase a ticket.', @@ -103,22 +194,18 @@ export async function rsvpAction(eventId: string, opts: Record, ); return; } + } - // Refuse questionnaire-gated events. The CLI cannot yet capture or submit - // questionnaire answers (live recon of the addGuest answer shape is still - // pending, see .wayfinder/tickets/07), so we refuse rather than silently - // submit an incomplete RSVP. When --answer support lands, gate this on it. - // NOTE: eventRequiresQuestionnaire field names are UNVERIFIED against a - // real /getEventInfo payload; confirm via CDP recon before trusting it as - // a positive-detection guarantee. - if (eventRequiresQuestionnaire(event)) { - jsonError( - 'This event requires answering a host questionnaire before you can RSVP. The CLI cannot submit questionnaire answers yet; please RSVP in the Partiful app.', - 3, - 'validation_error' - ); - return; - } + const existingResponse = currentGuest?.['questionnaireResponse'] as QuestionnaireResponse | undefined; + let questionnaireResponse: QuestionnaireResponse | null = existingResponse ?? null; + if (eventRequiresQuestionnaire(event)) { + questionnaireResponse = buildQuestionnaireResponse(event!, suppliedAnswers, existingResponse ?? null); + } else if (answerPairs.length > 0) { + throw new PartifulError( + 'This event does not expose a host questionnaire, so --answer cannot be used.', + 3, + 'validation_error' + ); } const name = resolveDisplayName({ @@ -128,16 +215,35 @@ export async function rsvpAction(eventId: string, opts: Record, tokenName: nameFromToken(token), }); + const suppliedPlusOnes = opts['plusOne'] as string[] | undefined; + const existingPlusOnes = currentGuest?.['plusOnes']; + const plusOnes = suppliedPlusOnes + ?? (Array.isArray(existingPlusOnes) ? existingPlusOnes as string[] : undefined); + const count = opts['count'] !== undefined + ? opts['count'] as number + : suppliedPlusOnes !== undefined + ? undefined + : currentGuest?.['count'] as number | undefined; + const message = opts['message'] !== undefined + ? opts['message'] as string + : currentGuest?.['rsvpMessage'] as string | null | undefined; + const currentStatus = currentGuest?.['status']; + const reusableCurrentStatus = typeof currentStatus === 'string' + && ['GOING', 'MAYBE', 'DECLINED'].includes(currentStatus.trim().toUpperCase()) + ? currentStatus + : undefined; + const params = buildRsvpParams({ eventId, name: name ?? undefined, - status: opts['status'] as string | undefined, - plusOnes: opts['plusOne'] as string[] | undefined, - count: opts['count'] as number | undefined, - message: opts['message'] as string | undefined, + status: (opts['status'] as string | undefined) ?? reusableCurrentStatus, + plusOnes, + count, + message, password: opts['password'] as string | undefined, timezone: opts['timezone'] as string | undefined, guestId: (currentGuest?.['id'] as string | undefined) ?? null, + questionnaireResponse, }); const payload = makePayload(config, params as unknown as Record); @@ -159,13 +265,43 @@ export async function rsvpAction(eventId: string, opts: Record, const result = await apiRequest('POST', '/addGuest', token, payload, globalOpts['verbose'] as boolean | undefined) as { result?: { data?: { guest?: Record } | Record } }; const guest = (result.result?.data as { guest?: Record })?.guest ?? result.result?.data ?? {}; + const guestId = (guest as Record)['id'] ?? currentGuest?.['id'] ?? null; + + let persistedStatus: string | null = null; + let persistedQuestionnaireResponse: QuestionnaireResponse | null = null; + let verificationError: string | null = null; + if (typeof guestId === 'string') { + try { + const document = await fetchGuestDocument( + token, + eventId, + guestId, + globalOpts['verbose'] as boolean | undefined, + ); + persistedStatus = document.fields?.['status']?.stringValue ?? null; + persistedQuestionnaireResponse = questionnaireResponseFromDocument(document); + } catch (error) { + verificationError = (error as Error).message; + } + } + + const expectedQuestionnaireResponse = params.rsvp.questionnaireResponse ?? null; + const verified = { + status: persistedStatus === params.rsvp.status, + questionnaireResponse: expectedQuestionnaireResponse === null + ? null + : questionnaireResponsesEqual(persistedQuestionnaireResponse, expectedQuestionnaireResponse), + }; jsonOutput({ eventId, status: params.rsvp.status, - guestId: (guest as Record)['id'] ?? currentGuest?.['id'] ?? null, + guestId, count: params.rsvp.count, updated: Boolean(currentGuest), + questionnaireResponse: persistedQuestionnaireResponse, + verified, + ...(verificationError ? { verificationError } : {}), url: `https://partiful.com/e/${eventId}`, }); } catch (e) { @@ -205,19 +341,30 @@ async function interestedAction(eventId: string, opts: Record, } } -/** Attach rsvp + interested subcommands to a parent command (events or explore). */ +/** Attach RSVP/interest subcommands to a parent command (events or explore). */ function attachRsvpVerbs(parent: Command): void { - parent + const rsvp = parent .command('rsvp') + .description('Read or change your RSVP'); + + rsvp + .command('get') + .description('Read your RSVP status and questionnaire answers') + .argument('', 'Event ID') + .action(currentGuestAction); + + rsvp + .command('set') .description('RSVP to an event (going, maybe, or declined)') .argument('', 'Event ID') - .option('--status ', `RSVP status: ${RSVP_STATUSES.join(', ')}`, 'going') + .option('--status ', `RSVP status: ${RSVP_STATUSES.join(', ')}`) .option('--name ', 'Display name to RSVP with (defaults to your profile name)') .option('--plus-one ', 'Plus-one name (repeatable)') .option('--count ', 'Total headcount including plus-ones', (v: string) => parseInt(v, 10)) .option('--message ', 'Optional public comment on the event') .option('--password ', 'Event password (if the event is password-gated)') .option('--timezone ', 'IANA timezone for the RSVP') + .option('--answer ', 'Host-questionnaire answers (question id or exact text)') .action(rsvpAction); parent diff --git a/src/commands/schema.ts b/src/commands/schema.ts index db257eb..c419bdf 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -17,6 +17,18 @@ interface CommandSchema { parameters: Record; } +const RSVP_PARAMETERS: Record = { + eventId: { type: 'string', required: true, positional: true, description: 'Event ID' }, + '--status': { type: 'string', required: false, description: 'RSVP status: going, maybe, declined (defaults to your existing status, or going for a new RSVP)' }, + '--name': { type: 'string', required: false, description: 'Display name to RSVP with (defaults to profile name)' }, + '--plus-one': { type: 'string[]', required: false, description: 'Plus-one name (repeatable)' }, + '--count': { type: 'integer', required: false, description: 'Total headcount including plus-ones' }, + '--message': { type: 'string', required: false, description: 'Optional public comment on the event' }, + '--password': { type: 'string', required: false, description: 'Event password (if password-gated)' }, + '--timezone': { type: 'string', required: false, description: 'IANA timezone for the RSVP' }, + '--answer': { type: 'string[]', required: false, description: 'Host-questionnaire answer as key=value (repeatable; key is question ID or exact text)' }, +}; + const SCHEMAS: Record = { 'events.list': { command: 'events list', @@ -72,19 +84,16 @@ const SCHEMAS: Record = { eventId: { type: 'string', required: true, positional: true }, }, }, - 'events.rsvp': { - command: 'events rsvp ', + 'events.rsvp.get': { + command: 'events rsvp get ', parameters: { eventId: { type: 'string', required: true, positional: true, description: 'Event ID' }, - '--status': { type: 'string', required: false, default: 'going', description: 'RSVP status: going, maybe, declined' }, - '--name': { type: 'string', required: false, description: 'Display name to RSVP with (defaults to profile name)' }, - '--plus-one': { type: 'string[]', required: false, description: 'Plus-one name (repeatable)' }, - '--count': { type: 'integer', required: false, description: 'Total headcount including plus-ones' }, - '--message': { type: 'string', required: false, description: 'Optional public comment on the event' }, - '--password': { type: 'string', required: false, description: 'Event password (if password-gated)' }, - '--timezone': { type: 'string', required: false, description: 'IANA timezone for the RSVP' }, }, }, + 'events.rsvp.set': { + command: 'events rsvp set ', + parameters: RSVP_PARAMETERS, + }, 'events.interested': { command: 'events interested ', parameters: { @@ -92,19 +101,16 @@ const SCHEMAS: Record = { '--remove': { type: 'boolean', required: false, description: 'Remove interest instead of adding it' }, }, }, - 'explore.rsvp': { - command: 'explore rsvp ', + 'explore.rsvp.get': { + command: 'explore rsvp get ', parameters: { eventId: { type: 'string', required: true, positional: true, description: 'Event ID' }, - '--status': { type: 'string', required: false, default: 'going', description: 'RSVP status: going, maybe, declined' }, - '--name': { type: 'string', required: false, description: 'Display name to RSVP with (defaults to profile name)' }, - '--plus-one': { type: 'string[]', required: false, description: 'Plus-one name (repeatable)' }, - '--count': { type: 'integer', required: false, description: 'Total headcount including plus-ones' }, - '--message': { type: 'string', required: false, description: 'Optional public comment on the event' }, - '--password': { type: 'string', required: false, description: 'Event password (if password-gated)' }, - '--timezone': { type: 'string', required: false, description: 'IANA timezone for the RSVP' }, }, }, + 'explore.rsvp.set': { + command: 'explore rsvp set ', + parameters: RSVP_PARAMETERS, + }, 'explore.interested': { command: 'explore interested ', parameters: { diff --git a/src/lib/api/endpoints.ts b/src/lib/api/endpoints.ts index 3478d30..d1a2858 100644 --- a/src/lib/api/endpoints.ts +++ b/src/lib/api/endpoints.ts @@ -478,6 +478,14 @@ export const apiEndpoints = { requestParams: ['eventId'], responseFields: fieldsOf(FirestoreDocumentSchema), }, + firestoreGetGuest: { + method: 'GET', + host: HOST_FIRESTORE, + path: '/v1/projects/getpartiful/databases/(default)/documents/events/{eventId}/guests/{guestId}', + transport: 'firestore', + requestParams: ['eventId', 'guestId'], + responseFields: fieldsOf(FirestoreDocumentSchema), + }, firestorePatchEvent: { method: 'PATCH', host: HOST_FIRESTORE, @@ -532,6 +540,7 @@ export const responseSchemas = { markEventInterest: MarkEventInterestResponseSchema, getCurrentGuest: GetCurrentGuestResponseSchema, firestoreGetEvent: FirestoreDocumentSchema, + firestoreGetGuest: FirestoreDocumentSchema, firestorePatchEvent: FirestoreDocumentSchema, firestoreListDocuments: FirestoreListResponseSchema, refreshToken: RefreshTokenResponseSchema, diff --git a/src/lib/http.ts b/src/lib/http.ts index 323b409..e076032 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -10,6 +10,7 @@ import { reportDrift, unwrapPayload } from './drift.js'; const API_BASE = 'https://api.partiful.com'; const FIRESTORE_BASE = 'https://firestore.googleapis.com'; const FIRESTORE_PROJECT = 'getpartiful'; +const FIRESTORE_GET_TIMEOUT_MS = 30_000; // Reverse map: endpoint path (e.g. '/createEvent') → spec method name, so the // central request path can diff live responses against the spec (T6 drift). @@ -164,6 +165,7 @@ export async function firestoreRequest( return text ? JSON.parse(text) : {}; } +/** Read one Firestore document by its path beneath /documents. */ export async function firestoreGetDocument( documentPath: string, token: string, @@ -174,6 +176,7 @@ export async function firestoreGetDocument( const resp = await withRetry( () => fetch(`${FIRESTORE_BASE}${fsPath}`, { method: 'GET', + signal: AbortSignal.timeout(FIRESTORE_GET_TIMEOUT_MS), headers: { Authorization: `Bearer ${token}`, Referer: 'https://partiful.com/', diff --git a/src/lib/rsvp.ts b/src/lib/rsvp.ts index ad31f39..762b77a 100644 --- a/src/lib/rsvp.ts +++ b/src/lib/rsvp.ts @@ -1,7 +1,7 @@ /** * RSVP / interest library for the Partiful CLI. * - * Pure builders + guards backing the `events rsvp` / `explore rsvp` and + * Pure builders + guards backing the `events rsvp set` / `explore rsvp set` and * `events interested` / `explore interested` commands. All network access lives * in the command layer (via src/lib/http.js); this module stays side-effect free * and unit-testable. @@ -56,6 +56,30 @@ export interface QuestionnaireResponse { answers: Record; } +/** Parse repeatable `--answer key=value` options into a lookup map. */ +export function parseQuestionnaireAnswers(pairs: string[] = []): Record { + const answers: Record = Object.create(null); + for (const pair of pairs) { + const separator = pair.indexOf('='); + if (separator < 0) { + throw new PartifulError(`Invalid --answer "${pair}". Use key=value.`, 3, 'validation_error'); + } + const key = pair.slice(0, separator).trim(); + const value = pair.slice(separator + 1).trim(); + if (!key) { + throw new PartifulError('Invalid --answer: question key cannot be empty.', 3, 'validation_error'); + } + if (!value) { + throw new PartifulError(`Invalid --answer "${pair}": value cannot be empty.`, 3, 'validation_error'); + } + if (Object.hasOwn(answers, key)) { + throw new PartifulError(`Duplicate questionnaire answer key: ${key}`, 3, 'validation_error'); + } + answers[key] = value; + } + return answers; +} + /** Loose event shape read by the RSVP guards (broad — hosts see more fields). */ export interface RsvpEvent { ticketing?: { enabled?: boolean }; @@ -134,6 +158,13 @@ export function buildRsvpParams(o: BuildRsvpOptions = {}): AddGuestParams { } else { count = Math.trunc(o.count); } + if (o.count != null && count < derivedCount) { + throw new PartifulError( + `Invalid --count "${o.count}". Count must include you and all named plus-ones (minimum ${derivedCount}).`, + 3, + 'validation_error', + ); + } const rsvp: RsvpDraft = { name: String(name), @@ -216,46 +247,110 @@ export function eventRequiresQuestionnaire(event: RsvpEvent | null | undefined): export function buildQuestionnaireResponse( event: RsvpEvent, answersByKey: Record = {}, + existingResponse: QuestionnaireResponse | null = null, ): QuestionnaireResponse | null { if (!eventRequiresQuestionnaire(event)) return null; - // Resolve question list defensively — event.questionnaire may be absent - // when the questionnaire was detected via a legacy field path. - let questions: Array; + let rawQuestions: Array; const primaryQuestions = event.questionnaire?.questions; if (Array.isArray(primaryQuestions) && primaryQuestions.length > 0) { - questions = primaryQuestions; + rawQuestions = primaryQuestions; } else { const legacyField = LEGACY_QUESTIONNAIRE_FIELDS.find( (f) => Array.isArray(event[f]) && (event[f] as unknown[]).length > 0, ); - if (legacyField) { - questions = event[legacyField] as Array; - } else { + if (!legacyField) { throw new PartifulError( 'Event questionnaire is enabled but no questions were found.', 3, 'validation_error', ); } + rawQuestions = event[legacyField] as Array; + } + + // For status-only edits, preserve the exact server-accepted response and version. + // Rebuilding under a newer host schema would drop answers or demand new ones. + if (existingResponse && Object.keys(answersByKey).length === 0) return existingResponse; + + const normalizeQuestion = (question: QuestionnaireQuestion | string): QuestionnaireQuestion => + typeof question === 'string' + ? { id: question, text: question, required: false } + : question; + const questions = rawQuestions.map(normalizeQuestion); + + let questionnaireVersion = 0; + const versions = event.questionnaireVersions; + if (Array.isArray(versions) && versions.length > 0) { + const latestQuestions = (versions.at(-1) as { questions?: Array } | undefined)?.questions; + const latest = Array.isArray(latestQuestions) ? latestQuestions.map(normalizeQuestion) : []; + const sameQuestion = (left: QuestionnaireQuestion, right: QuestionnaireQuestion): boolean => + left.id === right.id + && left.text === right.text + && left.type === right.type + && Boolean(left.required) === Boolean(right.required); + if (latest.length !== questions.length || latest.some((question, index) => !sameQuestion(question, questions[index]!))) { + throw new PartifulError( + 'The active questionnaire does not match its latest version history. Update answers in Partiful.', + 3, + 'validation_error', + ); + } + questionnaireVersion = versions.length - 1; + } else { + throw new PartifulError( + 'Event questionnaire version history is missing. Update answers in Partiful.', + 3, + 'validation_error', + ); + } + + if (existingResponse && existingResponse.questionnaireVersion !== questionnaireVersion) { + throw new PartifulError( + 'The event questionnaire changed after your existing response. Update answers in Partiful.', + 3, + 'validation_error', + ); + } + + const questionsByAlias = new Map(); + for (const question of questions) { + for (const alias of new Set([question.id, question.text])) { + const matches = questionsByAlias.get(alias) ?? []; + matches.push(question); + questionsByAlias.set(alias, matches); + } } - const answers: Record = {}; - const missing: string[] = []; - for (const rawQuestion of questions) { - // Normalise bare-string legacy questions: treat the string as both id and text. - const question: QuestionnaireQuestion = - typeof rawQuestion === 'string' - ? { id: rawQuestion, text: rawQuestion, required: false } - : rawQuestion; - // Accept an answer supplied under the question id OR its exact text. - const val = answersByKey[question.id] ?? answersByKey[question.text] ?? undefined; - if (val === undefined || val === null || String(val).trim() === '') { - if (question.required) missing.push(question.text); - continue; + const answers: Record = { ...existingResponse?.answers }; + const overriddenQuestionIds = new Set(); + for (const [key, rawValue] of Object.entries(answersByKey)) { + const matches = questionsByAlias.get(key) ?? []; + if (matches.length > 1) { + throw new PartifulError( + `Ambiguous questionnaire answer key "${key}". Update this questionnaire in Partiful.`, + 3, + 'validation_error', + ); + } + const question = matches[0]; + if (!question) { + throw new PartifulError(`Unknown questionnaire answer key: ${key}`, 3, 'validation_error'); + } + if (overriddenQuestionIds.has(question.id)) { + throw new PartifulError( + `Multiple answers target the same question: ${question.text}`, + 3, + 'validation_error', + ); } - answers[question.id] = String(val); + overriddenQuestionIds.add(question.id); + answers[question.id] = String(rawValue); } + + const missing = questions + .filter((question) => question.required && !String(answers[question.id] ?? '').trim()) + .map((question) => question.text); if (missing.length > 0) { throw new PartifulError( `Missing answer(s) for required question(s): ${missing.join('; ')}`, @@ -263,13 +358,7 @@ export function buildQuestionnaireResponse( 'validation_error', ); } - return { - // Version index into questionnaireVersions; current is the last entry. - questionnaireVersion: Array.isArray(event.questionnaireVersions) - ? Math.max(0, event.questionnaireVersions.length - 1) - : 0, - answers, - }; + return { questionnaireVersion, answers }; } /** Inputs for resolveDisplayName(). */ diff --git a/tests/http.test.js b/tests/http.test.js index 4383a29..f6b32bc 100644 --- a/tests/http.test.js +++ b/tests/http.test.js @@ -1,5 +1,14 @@ -import { describe, it, expect } from 'vitest'; -import { apiRequest, firestoreRequest, firestoreListDocuments, firestoreGetDocument } from '../src/lib/http.js'; +import { afterEach, describe, it, expect, vi } from 'vitest'; +import { + apiRequest, + firestoreGetDocument, + firestoreRequest, + firestoreListDocuments, +} from '../src/lib/http.js'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); describe('http module exports', () => { it('exports apiRequest as function', () => { @@ -14,4 +23,26 @@ describe('http module exports', () => { it('exports firestoreGetDocument as function', () => { expect(typeof firestoreGetDocument).toBe('function'); }); + + it('bounds Firestore document reads with an abort signal', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + text: async () => '{}', + }); + vi.stubGlobal('fetch', fetchMock); + + await firestoreGetDocument('events/EV1/guests/G7', 'token'); + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: 'GET', + signal: expect.any(AbortSignal), + headers: expect.objectContaining({ + Authorization: 'Bearer token', + Referer: 'https://partiful.com/', + }), + }), + ); + }); }); diff --git a/tests/rsvp-integration.test.js b/tests/rsvp-integration.test.js index 840c905..2499553 100644 --- a/tests/rsvp-integration.test.js +++ b/tests/rsvp-integration.test.js @@ -1,7 +1,7 @@ /** * Integration tests for the RSVP / interest command surface. * - * Covers `events rsvp` / `explore rsvp` and `events interested` / + * Covers `events rsvp set` / `explore rsvp set` and `events interested` / * `explore interested` via --dry-run (no network). The alias (`explore *`) must * forward to the SAME handler and produce an identical payload to `events *`. */ @@ -9,9 +9,9 @@ import { describe, it, expect } from 'vitest'; import { run, runRaw } from './helpers.js'; -describe('events rsvp / explore rsvp', () => { - it('events rsvp --dry-run builds an addGuest payload (GOING default)', () => { - const out = run(['events', 'rsvp', 'EV123', '--name', 'Kaleb Cole', '--dry-run', '--yes']); +describe('events rsvp set / explore rsvp set', () => { + it('events rsvp set --dry-run builds an addGuest payload (GOING default)', () => { + const out = run(['events', 'rsvp', 'set', 'EV123', '--name', 'Kaleb Cole', '--dry-run', '--yes']); expect(out.status).toBe('success'); expect(out.data.dryRun).toBe(true); expect(out.data.endpoint).toBe('/addGuest'); @@ -24,13 +24,13 @@ describe('events rsvp / explore rsvp', () => { }); it('--status maybe maps to the MAYBE wire enum', () => { - const out = run(['events', 'rsvp', 'EV123', '--name', 'Kaleb', '--status', 'maybe', '--dry-run', '--yes']); + const out = run(['events', 'rsvp', 'set', 'EV123', '--name', 'Kaleb', '--status', 'maybe', '--dry-run', '--yes']); expect(out.data.payload.data.params.rsvp.status).toBe('MAYBE'); }); it('--plus-one is repeatable and bumps the count', () => { const out = run([ - 'events', 'rsvp', 'EV123', '--name', 'Kaleb', + 'events', 'rsvp', 'set', 'EV123', '--name', 'Kaleb', '--plus-one', 'Maddie', '--plus-one', 'Justin', '--dry-run', '--yes', ]); @@ -40,22 +40,22 @@ describe('events rsvp / explore rsvp', () => { }); it('rejects an invalid --status with exit code 3', () => { - const { stdout, exitCode } = runRaw(['events', 'rsvp', 'EV123', '--name', 'Kaleb', '--status', 'interested', '--dry-run', '--yes']); + const { stdout, exitCode } = runRaw(['events', 'rsvp', 'set', 'EV123', '--name', 'Kaleb', '--status', 'interested', '--dry-run', '--yes']); const out = JSON.parse(stdout.trim()); expect(out.status).toBe('error'); expect(out.error.code).toBe(3); expect(exitCode).toBe(3); }); - it('explore rsvp forwards to the SAME handler with an identical payload', () => { - const viaEvents = run(['events', 'rsvp', 'EV123', '--name', 'Kaleb', '--status', 'going', '--dry-run', '--yes']); - const viaExplore = run(['explore', 'rsvp', 'EV123', '--name', 'Kaleb', '--status', 'going', '--dry-run', '--yes']); + it('explore rsvp set forwards to the SAME handler with an identical payload', () => { + const viaEvents = run(['events', 'rsvp', 'set', 'EV123', '--name', 'Kaleb', '--status', 'going', '--dry-run', '--yes']); + const viaExplore = run(['explore', 'rsvp', 'set', 'EV123', '--name', 'Kaleb', '--status', 'going', '--dry-run', '--yes']); expect(viaExplore.data.endpoint).toBe('/addGuest'); expect(viaExplore.data.payload.data.params).toEqual(viaEvents.data.payload.data.params); }); it('requires a name when none can be resolved (no auth profile in test)', () => { - const { stdout } = runRaw(['events', 'rsvp', 'EV123', '--dry-run', '--yes']); + const { stdout } = runRaw(['events', 'rsvp', 'set', 'EV123', '--dry-run', '--yes']); const out = JSON.parse(stdout.trim()); expect(out.status).toBe('error'); expect(out.error.code).toBe(3); diff --git a/tests/rsvp-orchestration.test.js b/tests/rsvp-orchestration.test.js index b7e9245..b1d37df 100644 --- a/tests/rsvp-orchestration.test.js +++ b/tests/rsvp-orchestration.test.js @@ -8,11 +8,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; // --- module mocks ----------------------------------------------------------- const apiRequest = vi.fn(); +const firestoreGetDocument = vi.fn(); const jsonOutput = vi.fn(); const jsonError = vi.fn(); const confirm = vi.fn(); -vi.mock('../src/lib/http.js', () => ({ apiRequest: (...a) => apiRequest(...a) })); +vi.mock('../src/lib/http.js', () => ({ + apiRequest: (...a) => apiRequest(...a), + firestoreGetDocument: (...a) => firestoreGetDocument(...a), +})); vi.mock('../src/lib/output.js', () => ({ jsonOutput: (...a) => jsonOutput(...a), jsonError: (...a) => jsonError(...a), @@ -26,13 +30,19 @@ vi.mock('../src/lib/auth.js', () => ({ })); vi.mock('../src/lib/events.js', () => ({ confirm: (...a) => confirm(...a) })); -const { rsvpAction } = await import('../src/commands/rsvp.js'); +const { currentGuestAction, rsvpAction } = await import('../src/commands/rsvp.js'); // Commander-like cmd stub whose optsWithGlobals returns the given globals. const mkCmd = (globals = {}) => ({ optsWithGlobals: () => globals }); // Route apiRequest by endpoint so each test can script the reads/writes. -function routeApi({ event = null, currentGuest = null, addGuest = { id: 'NEW' }, throwOn = null } = {}) { +function routeApi({ + event = null, + currentGuest = null, + addGuest = { id: 'NEW' }, + guestDocument = { fields: {} }, + throwOn = null, +} = {}) { apiRequest.mockImplementation(async (_m, endpoint) => { if (throwOn && endpoint === throwOn) throw new Error(`boom ${endpoint}`); if (endpoint === '/getEventInfo') return { result: { data: { event } } }; @@ -40,6 +50,29 @@ function routeApi({ event = null, currentGuest = null, addGuest = { id: 'NEW' }, if (endpoint === '/addGuest') return { result: { data: { guest: addGuest } } }; return { result: { data: {} } }; }); + firestoreGetDocument.mockResolvedValue(guestDocument); +} + +function firestoreGuestDocument({ status = 'GOING', version = 1, answers = { Q1: 'Yes!' } } = {}) { + return { + fields: { + status: { stringValue: status }, + questionnaireResponse: { + mapValue: { + fields: { + questionnaireVersion: { integerValue: String(version) }, + answers: { + mapValue: { + fields: Object.fromEntries( + Object.entries(answers).map(([id, answer]) => [id, { stringValue: answer }]), + ), + }, + }, + }, + }, + }, + }, + }; } beforeEach(() => { @@ -61,6 +94,21 @@ describe('rsvpAction edit path (existing guest record)', () => { expect(out.updated).toBe(true); expect(out.guestId).toBe('G7'); }); + + it('defaults non-self guest statuses to GOING when status is omitted', async () => { + routeApi({ currentGuest: { id: 'G7', name: 'Server Name', status: 'SENT' }, addGuest: { id: 'G7' } }); + await rsvpAction('EV1', { message: 'I can make it' }, mkCmd({ yes: true })); + + const addCall = apiRequest.mock.calls.find(c => c[1] === '/addGuest'); + expect(addCall[3].data.params.rsvp.status).toBe('GOING'); + }); + + it('keeps a plain dry-run offline', async () => { + await rsvpAction('EV1', { name: 'Kaleb' }, mkCmd({ dryRun: true })); + + expect(apiRequest).not.toHaveBeenCalled(); + expect(jsonOutput).toHaveBeenCalled(); + }); }); describe('rsvpAction ticketed guard', () => { @@ -71,16 +119,286 @@ describe('rsvpAction ticketed guard', () => { expect(jsonError).toHaveBeenCalledWith(expect.stringMatching(/ticketed|paid/i), 3, 'validation_error'); expect(apiRequest.mock.calls.some(c => c[1] === '/addGuest')).toBe(false); }); + + it('applies the ticket guard to answer-aware dry-runs', async () => { + routeApi({ event: { ticketing: { enabled: true } } }); + await rsvpAction('EV1', { answer: ['q1=value'] }, mkCmd({ yes: true, dryRun: true })); + + expect(jsonError).toHaveBeenCalledWith(expect.stringMatching(/ticketed|paid/i), 3, 'validation_error'); + expect(jsonOutput).not.toHaveBeenCalled(); + expect(apiRequest.mock.calls.some(c => c[1] === '/addGuest')).toBe(false); + }); }); describe('rsvpAction questionnaire guard', () => { - it('refuses a questionnaire-gated event and never calls addGuest', async () => { - routeApi({ event: { questions: [{ id: 'q1', required: true }] } }); + const questionnaireQuestions = [ + { id: 'q1', text: 'Required answer?', required: true }, + { id: 'q2', text: 'Optional answer?', required: false }, + ]; + const questionnaireEvent = { + questionnaireEnabled: true, + questionnaireVersions: [{ questions: questionnaireQuestions }], + questionnaire: { questions: questionnaireQuestions }, + }; + + it('preserves existing answers during a status-only update', async () => { + const existingResponse = { + questionnaireVersion: 0, + answers: { q1: 'Saved', q2: 'Also saved' }, + }; + routeApi({ + event: questionnaireEvent, + currentGuest: { id: 'G7', name: 'Kaleb', questionnaireResponse: existingResponse }, + addGuest: { id: 'G7' }, + }); + + await rsvpAction('EV1', { status: 'maybe' }, mkCmd({ yes: true })); + + const addCall = apiRequest.mock.calls.find(c => c[1] === '/addGuest'); + expect(addCall[3].data.params.rsvp.questionnaireResponse).toEqual(existingResponse); + }); + + it('reads omitted questionnaire answers from the Firestore guest document and verifies them after write', async () => { + const persistedResponse = { + questionnaireVersion: 1, + answers: { q1: 'Saved', q2: 'Also saved' }, + }; + routeApi({ + event: questionnaireEvent, + currentGuest: { id: 'G7', name: 'Kaleb', status: 'GOING' }, + addGuest: { id: 'G7' }, + guestDocument: { + fields: { + status: { stringValue: 'MAYBE' }, + questionnaireResponse: { + mapValue: { + fields: { + questionnaireVersion: { integerValue: '1' }, + answers: { + mapValue: { + fields: { + q1: { stringValue: 'Saved' }, + q2: { stringValue: 'Also saved' }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + await rsvpAction('EV1', { status: 'maybe' }, mkCmd({ yes: true })); + + expect(firestoreGetDocument).toHaveBeenCalledWith( + 'events/EV1/guests/G7', 'tok', false, + ); + const addCall = apiRequest.mock.calls.find(c => c[1] === '/addGuest'); + expect(addCall[3].data.params.rsvp.questionnaireResponse).toEqual(persistedResponse); + + const out = jsonOutput.mock.calls.at(-1)[0]; + expect(out.questionnaireResponse).toEqual(persistedResponse); + expect(out.verified).toEqual({ status: true, questionnaireResponse: true }); + }); + + it('verifies equivalent questionnaire answers regardless of Firestore key order', async () => { + const persistedDocument = firestoreGuestDocument({ + version: 0, + answers: { q2: 'Also saved', q1: 'Saved' }, + }); + routeApi({ + event: questionnaireEvent, + currentGuest: { id: 'G7', name: 'Kaleb', status: 'GOING' }, + addGuest: { id: 'G7' }, + }); + firestoreGetDocument + .mockResolvedValueOnce({ fields: { status: { stringValue: 'GOING' } } }) + .mockResolvedValueOnce(persistedDocument); + + await rsvpAction( + 'EV1', + { answer: ['q1=Saved', 'q2=Also saved'] }, + mkCmd({ yes: true }), + ); + + const out = jsonOutput.mock.calls.at(-1)[0]; + expect(out.verified.questionnaireResponse).toBe(true); + }); + + it('preserves the existing response after the host removes the questionnaire', async () => { + const existingResponse = { + questionnaireVersion: 0, + answers: { q1: 'Saved answer' }, + }; + routeApi({ + event: { questionnaireEnabled: false, questionnaire: { questions: [] } }, + currentGuest: { id: 'G7', name: 'Kaleb', questionnaireResponse: existingResponse }, + addGuest: { id: 'G7' }, + }); + + await rsvpAction('EV1', { status: 'maybe' }, mkCmd({ yes: true })); + + const addCall = apiRequest.mock.calls.find(c => c[1] === '/addGuest'); + expect(addCall[3].data.params.rsvp.questionnaireResponse).toEqual(existingResponse); + }); + + it('overrides one answer without dropping other existing answers', async () => { + routeApi({ + event: questionnaireEvent, + currentGuest: { + id: 'G7', + name: 'Kaleb', + status: 'MAYBE', + count: 2, + plusOnes: ['Friend'], + rsvpMessage: 'Saved message', + questionnaireResponse: { + questionnaireVersion: 0, + answers: { q1: 'Old', q2: 'Keep me' }, + }, + }, + addGuest: { id: 'G7' }, + }); + + await rsvpAction('EV1', { answer: ['q1=New'] }, mkCmd({ yes: true })); + + const addCall = apiRequest.mock.calls.find(c => c[1] === '/addGuest'); + expect(addCall[3].data.params.rsvp).toMatchObject({ + status: 'MAYBE', + count: 2, + plusOnes: ['Friend'], + message: 'Saved message', + questionnaireResponse: { + questionnaireVersion: 0, + answers: { q1: 'New', q2: 'Keep me' }, + }, + }); + }); + + it('uses the live read path for answer dry-runs and preserves existing state', async () => { + routeApi({ + event: questionnaireEvent, + currentGuest: { + id: 'G7', + name: 'Kaleb', + status: 'MAYBE', + questionnaireResponse: { + questionnaireVersion: 0, + answers: { q1: 'Old', q2: 'Keep me' }, + }, + }, + }); + + await rsvpAction('EV1', { answer: ['q1=New'] }, mkCmd({ yes: true, dryRun: true })); + + expect(apiRequest.mock.calls.some(c => c[1] === '/getCurrentGuest')).toBe(true); + const output = jsonOutput.mock.calls.at(-1)[0]; + expect(output.payload.data.params.rsvp).toMatchObject({ + guestId: 'G7', + status: 'MAYBE', + questionnaireResponse: { + questionnaireVersion: 0, + answers: { q1: 'New', q2: 'Keep me' }, + }, + }); + expect(apiRequest.mock.calls.some(c => c[1] === '/addGuest')).toBe(false); + }); + + it('refuses a questionnaire with an unanswered required question and never calls addGuest', async () => { + routeApi({ event: questionnaireEvent }); + await rsvpAction('EV1', { name: 'Kaleb' }, mkCmd({ yes: true })); + + expect(jsonError).toHaveBeenCalledWith(expect.stringMatching(/required question/i), 3, 'validation_error', null); + expect(apiRequest.mock.calls.some(c => c[1] === '/addGuest')).toBe(false); + }); + + it('allows an optional questionnaire with no answers', async () => { + const questions = [{ id: 'q1', text: 'Optional answer?', required: false }]; + routeApi({ + event: { + questionnaireEnabled: true, + questionnaireVersions: [{ questions }], + questionnaire: { questions }, + }, + }); await rsvpAction('EV1', { name: 'Kaleb' }, mkCmd({ yes: true })); - expect(jsonError).toHaveBeenCalledWith(expect.stringMatching(/questionnaire/i), 3, 'validation_error'); + const addCall = apiRequest.mock.calls.find(c => c[1] === '/addGuest'); + expect(addCall[3].data.params.rsvp.questionnaireResponse).toEqual({ + questionnaireVersion: 0, + answers: {}, + }); + }); + + it('submits supplied answers inside questionnaireResponse', async () => { + const questions = [{ id: 'q1', text: 'Dietary restrictions?', required: true }]; + routeApi({ + event: { + questionnaireEnabled: true, + questionnaireVersions: [{ questions }], + questionnaire: { questions }, + }, + }); + await rsvpAction('EV1', { name: 'Kaleb', answer: ['q1=Vegan'] }, mkCmd({ yes: true })); + + const addCall = apiRequest.mock.calls.find(c => c[1] === '/addGuest'); + expect(addCall).toBeDefined(); + expect(addCall[3].data.params.rsvp.questionnaireResponse).toEqual({ + questionnaireVersion: 0, + answers: { q1: 'Vegan' }, + }); + }); + + it('refuses incomplete supplied answers and never calls addGuest', async () => { + const questions = [{ id: 'q1', text: 'Dietary restrictions?', required: true }]; + routeApi({ + event: { + questionnaireEnabled: true, + questionnaireVersions: [{ questions }], + questionnaire: { questions }, + }, + }); + await rsvpAction('EV1', { name: 'Kaleb', answer: ['other=value'] }, mkCmd({ yes: true })); + + expect(jsonError).toHaveBeenCalledWith(expect.stringMatching(/unknown questionnaire answer key/i), 3, 'validation_error', null); expect(apiRequest.mock.calls.some(c => c[1] === '/addGuest')).toBe(false); }); + + it('refuses --answer for an event without a questionnaire', async () => { + routeApi({ event: { title: 'Plain party' }, currentGuest: { id: 'G7', name: 'Kaleb' } }); + await rsvpAction('EV1', { answer: ['q1=Vegan'] }, mkCmd({ yes: true })); + + expect(jsonError).toHaveBeenCalledWith( + expect.stringMatching(/does not expose a host questionnaire/i), 3, 'validation_error', null, + ); + expect(apiRequest.mock.calls.some(c => c[1] === '/addGuest')).toBe(false); + }); +}); + +describe('currentGuestAction', () => { + it('returns the caller RSVP with questionnaire answers from Firestore', async () => { + routeApi({ + currentGuest: { id: 'G7', status: 'GOING', count: 1 }, + guestDocument: firestoreGuestDocument(), + }); + + await currentGuestAction('EV1', {}, mkCmd()); + + expect(jsonOutput).toHaveBeenCalledWith({ + eventId: 'EV1', + guest: { + id: 'G7', + status: 'GOING', + count: 1, + questionnaireResponse: { + questionnaireVersion: 1, + answers: { Q1: 'Yes!' }, + }, + }, + url: 'https://partiful.com/e/EV1', + }); + }); }); describe('rsvpAction confirmation gate', () => { diff --git a/tests/rsvp.test.js b/tests/rsvp.test.js index 42e0fca..ef303e3 100644 --- a/tests/rsvp.test.js +++ b/tests/rsvp.test.js @@ -2,7 +2,7 @@ * Unit tests for the RSVP / interest library (src/lib/rsvp.js). * * These cover the PURE, side-effect-free builders and guards that back the - * `events rsvp` / `explore rsvp` and `events interested` / `explore interested` + * `events rsvp set` / `explore rsvp set` and `events interested` / `explore interested` * commands. All network/orchestration is tested separately via CLI dry-run * integration tests; here we pin the wire-payload shapes and the refusal guards. */ @@ -16,6 +16,7 @@ import { isTicketedEvent, eventRequiresQuestionnaire, buildQuestionnaireResponse, + parseQuestionnaireAnswers, resolveDisplayName, } from '../src/lib/rsvp.js'; @@ -88,6 +89,12 @@ describe('buildRsvpParams', () => { expect(params.rsvp.count).toBe(5); }); + it('rejects a count lower than self plus named plus-ones', () => { + expect(() => buildRsvpParams({ + eventId: 'EV1', name: 'Kaleb', plusOnes: ['Maddie', 'Justin'], count: 2, + })).toThrow(/count.*plus-one/i); + }); + it('carries message, password and timezone through', () => { const params = buildRsvpParams({ eventId: 'EV1', name: 'Kaleb', message: 'stoked', password: 'sesame', timezone: 'America/New_York', @@ -190,15 +197,14 @@ describe('resolveDisplayName', () => { // Event fields: questionnaireEnabled + questionnaire.questions[{id,type,text,required}]. // Answer storage: guest.questionnaireResponse = { questionnaireVersion, answers:{id:val} }. describe('questionnaire (verified shape)', () => { + const questions = [ + { id: '111', type: 'short_answer', text: 'Dietary restrictions?', required: true }, + { id: '222', type: 'short_answer', text: 'Song request?', required: false }, + ]; const qEvent = { questionnaireEnabled: true, - questionnaireVersions: [{ questions: [] }], - questionnaire: { - questions: [ - { id: '111', type: 'short_answer', text: 'Dietary restrictions?', required: true }, - { id: '222', type: 'short_answer', text: 'Song request?', required: false }, - ], - }, + questionnaireVersions: [{ questions }], + questionnaire: { questions }, }; it('detects a questionnaire via questionnaireEnabled + questions[]', () => { @@ -226,15 +232,131 @@ describe('questionnaire (verified shape)', () => { expect(resp.answers['111']).toBe('Vegan'); }); + it('merges supplied overrides with an existing response from the same version', () => { + const existing = { + questionnaireVersion: 0, + answers: { '111': 'None', '222': 'Jazz' }, + }; + const resp = buildQuestionnaireResponse(qEvent, { 'Dietary restrictions?': 'Vegan' }, existing); + expect(resp).toEqual({ + questionnaireVersion: 0, + answers: { '111': 'Vegan', '222': 'Jazz' }, + }); + }); + + it('preserves an existing response unchanged when no overrides are supplied', () => { + const existing = { + questionnaireVersion: 0, + answers: { '111': 'None', '222': 'Jazz' }, + }; + expect(buildQuestionnaireResponse(qEvent, {}, existing)).toBe(existing); + }); + + it('preserves the exact existing version during a status-only update after a host edit', () => { + const oldQuestions = questions; + const newQuestions = [{ id: '333', text: 'New required question?', required: true }]; + const changedEvent = { + questionnaireEnabled: true, + questionnaireVersions: [ + { questions: oldQuestions }, + { questions: newQuestions }, + ], + questionnaire: { questions: newQuestions }, + }; + const existing = { + questionnaireVersion: 0, + answers: { '111': 'None', '222': 'Jazz' }, + }; + expect(buildQuestionnaireResponse(changedEvent, {}, existing)).toBe(existing); + }); + + it('rejects duplicate question text instead of applying one answer twice', () => { + const ambiguousQuestions = [ + { id: '111', text: 'Anything else?', required: false }, + { id: '222', text: 'Anything else?', required: false }, + ]; + const ambiguousEvent = { + ...qEvent, + questionnaireVersions: [{ questions: ambiguousQuestions }], + questionnaire: { questions: ambiguousQuestions }, + }; + expect(() => buildQuestionnaireResponse(ambiguousEvent, { 'Anything else?': 'No' })) + .toThrow(/ambiguous questionnaire answer key/i); + }); + + it('rejects ID and text aliases supplied for the same question', () => { + expect(() => buildQuestionnaireResponse(qEvent, { + '111': 'None', + 'Dietary restrictions?': 'Vegan', + })).toThrow(/multiple answers.*same question/i); + }); + + it('rejects a key that is one question ID and another question text', () => { + const collidingQuestions = [ + { id: 'shared', text: 'First question', required: false }, + { id: 'second', text: 'shared', required: false }, + ]; + const event = { + questionnaireEnabled: true, + questionnaireVersions: [{ questions: collidingQuestions }], + questionnaire: { questions: collidingQuestions }, + }; + expect(() => buildQuestionnaireResponse(event, { shared: 'Answer' })) + .toThrow(/ambiguous questionnaire answer key/i); + }); + + it('rejects partial updates against a newer questionnaire version', () => { + const existing = { questionnaireVersion: 0, answers: { '111': 'Saved' } }; + const changedEvent = { + ...qEvent, + questionnaireVersions: [ + { questions: [{ id: 'old', text: 'Old question', required: false }] }, + { questions }, + ], + }; + expect(() => buildQuestionnaireResponse(changedEvent, { '111': 'New' }, existing)) + .toThrow(/questionnaire changed/i); + }); + it('omits optional questions left unanswered', () => { const resp = buildQuestionnaireResponse(qEvent, { '111': 'None' }); expect(resp.answers).toEqual({ '111': 'None' }); }); + it('rejects supplied keys that match no question instead of silently dropping them', () => { + expect(() => buildQuestionnaireResponse(qEvent, { unknown: 'value', '111': 'None' })) + .toThrow(/unknown questionnaire answer key/i); + }); + it('throws when a required question is unanswered', () => { expect(() => buildQuestionnaireResponse(qEvent, { '222': 'Song' })).toThrow(/required question/i); }); + it('fails closed when verified questionnaire version history is missing', () => { + expect(() => buildQuestionnaireResponse({ + questionnaireEnabled: true, + questionnaire: { questions }, + }, { '111': 'None' })).toThrow(/version history/i); + }); + + it('fails closed when the latest version does not match the active questionnaire', () => { + expect(() => buildQuestionnaireResponse({ + ...qEvent, + questionnaireVersions: [{ questions: [{ ...questions[0], text: 'Old question' }] }], + }, { '111': 'None' })).toThrow(/does not match/i); + }); + + it('uses the matching latest questionnaire version index', () => { + const response = buildQuestionnaireResponse({ + ...qEvent, + questionnaireVersions: [ + { questions: [{ id: 'old', text: 'Old question', required: false }] }, + { questions }, + ], + }, { '111': 'None' }); + expect(response.questionnaireVersion).toBe(1); + }); + it('returns null for a non-questionnaire event', () => { expect(buildQuestionnaireResponse({ title: 'plain' }, {})).toBeNull(); }); @@ -251,6 +373,32 @@ describe('questionnaire (verified shape)', () => { }); }); +describe('parseQuestionnaireAnswers', () => { + it('parses repeated key=value answers and preserves equals signs in values', () => { + expect(parseQuestionnaireAnswers(['111=Vegan', 'Song request?=A=B'])).toEqual({ + '111': 'Vegan', + 'Song request?': 'A=B', + }); + }); + + it('rejects malformed or empty answer pairs', () => { + expect(() => parseQuestionnaireAnswers(['missing-separator'])).toThrow(/key=value/i); + expect(() => parseQuestionnaireAnswers(['=value'])).toThrow(/key/i); + expect(() => parseQuestionnaireAnswers(['111='])).toThrow(/value/i); + }); + + it('rejects a repeated answer key instead of silently taking the final value', () => { + expect(() => parseQuestionnaireAnswers(['111=None', '111=Vegan'])) + .toThrow(/duplicate questionnaire answer key/i); + }); + + it('retains prototype-like keys so unknown-key validation cannot be bypassed', () => { + const parsed = parseQuestionnaireAnswers(['__proto__=value']); + expect(Object.hasOwn(parsed, '__proto__')).toBe(true); + expect(parsed.__proto__).toBe('value'); + }); +}); + describe('buildRsvpParams — count validation (Fix 1)', () => { it('throws for count 0', () => { expect(() => buildRsvpParams({ eventId: 'EV1', name: 'A', count: 0 })) @@ -301,22 +449,34 @@ describe('buildQuestionnaireResponse — legacy questionnaire guard (Fix 2)', () } }); - it('returns a valid response object for legacy event with all required answers', () => { - const resp = buildQuestionnaireResponse(legacyEvent, { q1: 'Vegan' }); - expect(resp).not.toBeNull(); - expect(resp.answers).toHaveProperty('q1', 'Vegan'); - expect(typeof resp.questionnaireVersion).toBe('number'); + it('fails closed for a legacy event without version history', () => { + expect(() => buildQuestionnaireResponse(legacyEvent, { q1: 'Vegan' })) + .toThrow(/version history/i); + }); + + it('uses validated latest-version history for a legacy question field', () => { + const latestQuestions = legacyEvent.questions; + const event = { + ...legacyEvent, + questionnaireVersions: [ + { questions: [{ id: 'old', text: 'Old?', required: false }] }, + { questions: latestQuestions }, + ], + }; + expect(buildQuestionnaireResponse(event, { q1: 'Vegan' })).toEqual({ + questionnaireVersion: 1, + answers: { q1: 'Vegan' }, + }); }); it('still returns correct versioned answers for primary event.questionnaire shape', () => { + const questions = [ + { id: '111', type: 'short_answer', text: 'Dietary restrictions?', required: true }, + ]; const primaryEvent = { questionnaireEnabled: true, - questionnaireVersions: [{ questions: [] }], - questionnaire: { - questions: [ - { id: '111', type: 'short_answer', text: 'Dietary restrictions?', required: true }, - ], - }, + questionnaireVersions: [{ questions }], + questionnaire: { questions }, }; const resp = buildQuestionnaireResponse(primaryEvent, { '111': 'None' }); expect(resp).toEqual({ questionnaireVersion: 0, answers: { '111': 'None' } }); diff --git a/tests/schema-api.test.js b/tests/schema-api.test.js index feac8c4..13c2e52 100644 --- a/tests/schema-api.test.js +++ b/tests/schema-api.test.js @@ -53,6 +53,14 @@ describe('schema api. namespace', () => { expect(out.data.requestParams).toContain('eventId'); }); + it('`schema api.firestoreGetGuest` documents the current RSVP detail read path', () => { + const out = run(['schema', 'api.firestoreGetGuest']); + expect(out.data.transport).toBe('firestore'); + expect(out.data.httpMethod).toBe('GET'); + expect(out.data.requestParams).toEqual(expect.arrayContaining(['eventId', 'guestId'])); + expect(out.data.path).toContain('/events/{eventId}/guests/{guestId}'); + }); + it('unknown api method errors with not_found and lists available', () => { const { stdout, exitCode } = runRaw(['schema', 'api.nope']); expect(exitCode).toBe(4); diff --git a/tests/schema-rsvp.test.js b/tests/schema-rsvp.test.js index b608f9f..b25288d 100644 --- a/tests/schema-rsvp.test.js +++ b/tests/schema-rsvp.test.js @@ -5,19 +5,38 @@ import { describe, it, expect } from 'vitest'; import { run } from './helpers.js'; describe('schema covers rsvp / interested verbs', () => { - it('lists events.rsvp and events.interested', () => { + it('lists RSVP read, write, and interest commands', () => { const out = run(['schema']); - expect(out.data.commands).toContain('events.rsvp'); + expect(out.data.commands).toContain('events.rsvp.get'); + expect(out.data.commands).toContain('events.rsvp.set'); expect(out.data.commands).toContain('events.interested'); - expect(out.data.commands).toContain('explore.rsvp'); + expect(out.data.commands).toContain('explore.rsvp.get'); + expect(out.data.commands).toContain('explore.rsvp.set'); expect(out.data.commands).toContain('explore.interested'); + expect(out.data.commands).not.toContain('events.rsvp'); + expect(out.data.commands).not.toContain('events.my-rsvp'); }); - it('events.rsvp schema exposes status + plus-one params', () => { - const out = run(['schema', 'events.rsvp']); - expect(out.data.command).toContain('rsvp'); - expect(out.data.parameters['--status']).toBeDefined(); + it('events.rsvp.get exposes its event ID', () => { + const out = run(['schema', 'events.rsvp.get']); + expect(out.data.command).toBe('events rsvp get '); + expect(out.data.parameters.eventId).toEqual(expect.objectContaining({ + type: 'string', + required: true, + positional: true, + })); + }); + + it('events.rsvp.set schema exposes status, plus-one, and questionnaire answer params', () => { + const out = run(['schema', 'events.rsvp.set']); + expect(out.data.command).toBe('events rsvp set '); + expect(out.data.parameters['--status']).toEqual(expect.objectContaining({ + type: 'string', + description: expect.stringMatching(/existing status.*new RSVP/i), + })); + expect(out.data.parameters['--status']).not.toHaveProperty('default'); expect(out.data.parameters['--plus-one']).toBeDefined(); + expect(out.data.parameters['--answer']).toEqual(expect.objectContaining({ type: 'string[]' })); expect(out.data.parameters.eventId.required).toBe(true); }); diff --git a/tests/skill-structure.test.js b/tests/skill-structure.test.js index c165090..3cf7ffe 100644 --- a/tests/skill-structure.test.js +++ b/tests/skill-structure.test.js @@ -86,7 +86,7 @@ describe('bundled Partiful skill', () => { ['+watch', '--help'], ['+export', '--help'], ['+share', '--help'], - ['events', 'rsvp', '--help'], + ['events', 'rsvp', 'set', '--help'], ['blasts', 'send', '--help'], ]) { const { stdout, exitCode } = runRaw(command); @@ -94,7 +94,7 @@ describe('bundled Partiful skill', () => { expect(stdout).toContain('Usage: partiful'); } - expect(runRaw(['events', 'rsvp', '--help']).stdout).toContain('--plus-one'); + expect(runRaw(['events', 'rsvp', 'set', '--help']).stdout).toContain('--plus-one'); expect(runRaw(['blasts', 'send', '--help']).stdout).toContain('--no-show-on-event-page'); });