From bcb947489c6d354bd797348f2f8ba5dcbbf7b21d Mon Sep 17 00:00:00 2001 From: Kaleb Cole Date: Mon, 27 Jul 2026 22:57:49 -0700 Subject: [PATCH 1/6] feat: support questionnaire answers in rsvp command --- README.md | 9 ++-- .../partiful/references/rsvps-and-interest.md | 9 +++- src/commands/rsvp.ts | 39 ++++++++------ src/commands/schema.ts | 2 + src/lib/rsvp.ts | 36 ++++++++++++- tests/rsvp-orchestration.test.js | 54 +++++++++++++++++-- tests/rsvp.test.js | 21 ++++++++ tests/schema-rsvp.test.js | 3 +- 8 files changed, 147 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index ce04ca2..eb1d71e 100644 --- a/README.md +++ b/README.md @@ -104,14 +104,17 @@ 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 --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. +already RSVP'd, otherwise creates 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. The same verbs are available under +`explore` (`partiful explore rsvp `) for the discovery flow. ### `guests` — Manage event guests diff --git a/skills/partiful/references/rsvps-and-interest.md b/skills/partiful/references/rsvps-and-interest.md index 6436c20..4948617 100644 --- a/skills/partiful/references/rsvps-and-interest.md +++ b/skills/partiful/references/rsvps-and-interest.md @@ -12,4 +12,11 @@ partiful events interested --remove `explore rsvp` and `explore interested` are equivalent aliases. -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 --answer "=" +partiful events rsvp --answer "Dietary restrictions?=None" --answer "Song request?=Anything" +``` + +Required answers are validated before submission. 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..fa25630 100644 --- a/src/commands/rsvp.ts +++ b/src/commands/rsvp.ts @@ -22,6 +22,8 @@ import { buildInterestParams, isTicketedEvent, eventRequiresQuestionnaire, + buildQuestionnaireResponse, + parseQuestionnaireAnswers, resolveDisplayName, type RsvpEvent, } from '../lib/rsvp.js'; @@ -84,8 +86,11 @@ 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); + + // Read-before-write: decide create (guestId:null) vs update. A dry-run with + // answers still fetches the event to validate questions and version. let currentGuest: Record | null = null; let event: RsvpEvent | null = null; if (!globalOpts['dryRun']) { @@ -103,22 +108,19 @@ export async function rsvpAction(eventId: string, opts: Record, ); return; } + } else if (answerPairs.length > 0) { + event = await fetchEvent(config, token, eventId, globalOpts['verbose'] as boolean | undefined); + } - // 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; - } + let questionnaireResponse = null; + if (eventRequiresQuestionnaire(event)) { + questionnaireResponse = buildQuestionnaireResponse(event!, suppliedAnswers); + } 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({ @@ -138,6 +140,7 @@ export async function rsvpAction(eventId: string, opts: Record, 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); @@ -207,6 +210,7 @@ async function interestedAction(eventId: string, opts: Record, /** Attach rsvp + interested subcommands to a parent command (events or explore). */ function attachRsvpVerbs(parent: Command): void { + const collect = (value: string, previous: string[]): string[] => [...previous, value]; parent .command('rsvp') .description('RSVP to an event (going, maybe, or declined)') @@ -218,6 +222,7 @@ function attachRsvpVerbs(parent: Command): void { .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 answer; repeat for each answer (question id or exact text)', collect, []) .action(rsvpAction); parent diff --git a/src/commands/schema.ts b/src/commands/schema.ts index e190088..c6b480d 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -83,6 +83,7 @@ const SCHEMAS: Record = { '--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)' }, }, }, 'events.interested': { @@ -103,6 +104,7 @@ const SCHEMAS: Record = { '--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)' }, }, }, 'explore.interested': { diff --git a/src/lib/rsvp.ts b/src/lib/rsvp.ts index ad31f39..cbbac06 100644 --- a/src/lib/rsvp.ts +++ b/src/lib/rsvp.ts @@ -56,6 +56,27 @@ export interface QuestionnaireResponse { answers: Record; } +/** Parse repeatable `--answer key=value` options into a lookup map. */ +export function parseQuestionnaireAnswers(pairs: string[] = []): Record { + const answers: Record = {}; + 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'); + } + answers[key] = value; + } + return answers; +} + /** Loose event shape read by the RSVP guards (broad — hosts see more fields). */ export interface RsvpEvent { ticketing?: { enabled?: boolean }; @@ -242,6 +263,7 @@ export function buildQuestionnaireResponse( const answers: Record = {}; const missing: string[] = []; + const matchedKeys = new Set(); for (const rawQuestion of questions) { // Normalise bare-string legacy questions: treat the string as both id and text. const question: QuestionnaireQuestion = @@ -249,13 +271,25 @@ export function buildQuestionnaireResponse( ? { 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; + const hasId = Object.hasOwn(answersByKey, question.id); + const hasText = Object.hasOwn(answersByKey, question.text); + const val = hasId ? answersByKey[question.id] : hasText ? answersByKey[question.text] : undefined; + if (hasId) matchedKeys.add(question.id); + if (hasText) matchedKeys.add(question.text); if (val === undefined || val === null || String(val).trim() === '') { if (question.required) missing.push(question.text); continue; } answers[question.id] = String(val); } + const unknownKeys = Object.keys(answersByKey).filter((key) => !matchedKeys.has(key)); + if (unknownKeys.length > 0) { + throw new PartifulError( + `Unknown questionnaire answer key(s): ${unknownKeys.join('; ')}`, + 3, + 'validation_error', + ); + } if (missing.length > 0) { throw new PartifulError( `Missing answer(s) for required question(s): ${missing.join('; ')}`, diff --git a/tests/rsvp-orchestration.test.js b/tests/rsvp-orchestration.test.js index b7e9245..39da529 100644 --- a/tests/rsvp-orchestration.test.js +++ b/tests/rsvp-orchestration.test.js @@ -74,11 +74,59 @@ describe('rsvpAction ticketed guard', () => { }); describe('rsvpAction questionnaire guard', () => { - it('refuses a questionnaire-gated event and never calls addGuest', async () => { - routeApi({ event: { questions: [{ id: 'q1', required: true }] } }); + it('refuses a questionnaire with an unanswered required question and never calls addGuest', async () => { + routeApi({ event: { questions: [{ id: 'q1', text: 'Required answer?', required: true }] } }); await rsvpAction('EV1', { name: 'Kaleb' }, mkCmd({ yes: true })); - expect(jsonError).toHaveBeenCalledWith(expect.stringMatching(/questionnaire/i), 3, 'validation_error'); + 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 () => { + routeApi({ + event: { + questionnaireEnabled: true, + questionnaireVersions: [{ questions: [] }], + questionnaire: { questions: [{ id: 'q1', text: 'Optional answer?', required: false }] }, + }, + }); + await rsvpAction('EV1', { name: 'Kaleb' }, mkCmd({ yes: true })); + + 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 () => { + routeApi({ + event: { + questionnaireEnabled: true, + questionnaireVersions: [{ questions: [] }], + questionnaire: { questions: [{ id: 'q1', text: 'Dietary restrictions?', required: true }] }, + }, + }); + 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 () => { + routeApi({ + event: { + questionnaireEnabled: true, + questionnaire: { questions: [{ id: 'q1', text: 'Dietary restrictions?', required: true }] }, + }, + }); + 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); }); }); diff --git a/tests/rsvp.test.js b/tests/rsvp.test.js index 42e0fca..0315961 100644 --- a/tests/rsvp.test.js +++ b/tests/rsvp.test.js @@ -16,6 +16,7 @@ import { isTicketedEvent, eventRequiresQuestionnaire, buildQuestionnaireResponse, + parseQuestionnaireAnswers, resolveDisplayName, } from '../src/lib/rsvp.js'; @@ -231,6 +232,11 @@ describe('questionnaire (verified shape)', () => { 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); }); @@ -251,6 +257,21 @@ 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); + }); +}); + describe('buildRsvpParams — count validation (Fix 1)', () => { it('throws for count 0', () => { expect(() => buildRsvpParams({ eventId: 'EV1', name: 'A', count: 0 })) diff --git a/tests/schema-rsvp.test.js b/tests/schema-rsvp.test.js index b608f9f..e732bce 100644 --- a/tests/schema-rsvp.test.js +++ b/tests/schema-rsvp.test.js @@ -13,11 +13,12 @@ describe('schema covers rsvp / interested verbs', () => { expect(out.data.commands).toContain('explore.interested'); }); - it('events.rsvp schema exposes status + plus-one params', () => { + it('events.rsvp schema exposes status, plus-one, and questionnaire answer params', () => { const out = run(['schema', 'events.rsvp']); expect(out.data.command).toContain('rsvp'); expect(out.data.parameters['--status']).toBeDefined(); 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); }); From 84d4f03a0c5a03482bcd74917b5d26b49f40c5ff Mon Sep 17 00:00:00 2001 From: Kaleb Cole Date: Fri, 31 Jul 2026 15:30:39 -0700 Subject: [PATCH 2/6] fix(rsvp): preserve questionnaire state safely --- src/commands/rsvp.ts | 43 +++++--- src/commands/schema.ts | 36 +++---- src/lib/rsvp.ts | 120 ++++++++++++++++------- tests/rsvp-orchestration.test.js | 131 +++++++++++++++++++++++-- tests/rsvp.test.js | 163 +++++++++++++++++++++++++++---- 5 files changed, 396 insertions(+), 97 deletions(-) diff --git a/src/commands/rsvp.ts b/src/commands/rsvp.ts index fa25630..d741993 100644 --- a/src/commands/rsvp.ts +++ b/src/commands/rsvp.ts @@ -25,6 +25,7 @@ import { buildQuestionnaireResponse, parseQuestionnaireAnswers, resolveDisplayName, + type QuestionnaireResponse, type RsvpEvent, } from '../lib/rsvp.js'; @@ -89,17 +90,18 @@ export async function rsvpAction(eventId: string, opts: Record, const answerPairs = (opts['answer'] as string[] | undefined) ?? []; const suppliedAnswers = parseQuestionnaireAnswers(answerPairs); - // Read-before-write: decide create (guestId:null) vs update. A dry-run with - // answers still fetches the event to validate questions and version. + // 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.', @@ -108,13 +110,12 @@ export async function rsvpAction(eventId: string, opts: Record, ); return; } - } else if (answerPairs.length > 0) { - event = await fetchEvent(config, token, eventId, globalOpts['verbose'] as boolean | undefined); } - let questionnaireResponse = null; + const existingResponse = currentGuest?.['questionnaireResponse'] as QuestionnaireResponse | undefined; + let questionnaireResponse: QuestionnaireResponse | null = existingResponse ?? null; if (eventRequiresQuestionnaire(event)) { - questionnaireResponse = buildQuestionnaireResponse(event!, suppliedAnswers); + 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.', @@ -130,13 +131,26 @@ 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 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) ?? (currentGuest?.['status'] as string | undefined), + plusOnes, + count, + message, password: opts['password'] as string | undefined, timezone: opts['timezone'] as string | undefined, guestId: (currentGuest?.['id'] as string | undefined) ?? null, @@ -210,19 +224,18 @@ async function interestedAction(eventId: string, opts: Record, /** Attach rsvp + interested subcommands to a parent command (events or explore). */ function attachRsvpVerbs(parent: Command): void { - const collect = (value: string, previous: string[]): string[] => [...previous, value]; parent .command('rsvp') .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 answer; repeat for each answer (question id or exact text)', collect, []) + .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 c6b480d..20a2b64 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, 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' }, + '--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', @@ -74,17 +86,7 @@ const SCHEMAS: Record = { }, 'events.rsvp': { command: 'events rsvp ', - 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' }, - '--answer': { type: 'string[]', required: false, description: 'Host-questionnaire answer as key=value (repeatable; key is question ID or exact text)' }, - }, + parameters: RSVP_PARAMETERS, }, 'events.interested': { command: 'events interested ', @@ -95,17 +97,7 @@ const SCHEMAS: Record = { }, 'explore.rsvp': { command: 'explore rsvp ', - 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' }, - '--answer': { type: 'string[]', required: false, description: 'Host-questionnaire answer as key=value (repeatable; key is question ID or exact text)' }, - }, + parameters: RSVP_PARAMETERS, }, 'explore.interested': { command: 'explore interested ', diff --git a/src/lib/rsvp.ts b/src/lib/rsvp.ts index cbbac06..de4ce95 100644 --- a/src/lib/rsvp.ts +++ b/src/lib/rsvp.ts @@ -72,6 +72,9 @@ export function parseQuestionnaireAnswers(pairs: string[] = []): 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; } - const answers: Record = {}; - const missing: string[] = []; - const matchedKeys = new Set(); - 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 hasId = Object.hasOwn(answersByKey, question.id); - const hasText = Object.hasOwn(answersByKey, question.text); - const val = hasId ? answersByKey[question.id] : hasText ? answersByKey[question.text] : undefined; - if (hasId) matchedKeys.add(question.id); - if (hasText) matchedKeys.add(question.text); - if (val === undefined || val === null || String(val).trim() === '') { - if (question.required) missing.push(question.text); - continue; + // 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', + ); } - answers[question.id] = String(val); + questionnaireVersion = versions.length - 1; + } else { + throw new PartifulError( + 'Event questionnaire version history is missing. Update answers in Partiful.', + 3, + 'validation_error', + ); } - const unknownKeys = Object.keys(answersByKey).filter((key) => !matchedKeys.has(key)); - if (unknownKeys.length > 0) { + + if (existingResponse && existingResponse.questionnaireVersion !== questionnaireVersion) { throw new PartifulError( - `Unknown questionnaire answer key(s): ${unknownKeys.join('; ')}`, + '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 = { ...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', + ); + } + 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('; ')}`, @@ -297,13 +351,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/rsvp-orchestration.test.js b/tests/rsvp-orchestration.test.js index 39da529..adb05e2 100644 --- a/tests/rsvp-orchestration.test.js +++ b/tests/rsvp-orchestration.test.js @@ -71,11 +71,126 @@ 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', () => { + 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('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: { questions: [{ id: 'q1', text: 'Required answer?', required: true }] } }); + routeApi({ event: questionnaireEvent }); await rsvpAction('EV1', { name: 'Kaleb' }, mkCmd({ yes: true })); expect(jsonError).toHaveBeenCalledWith(expect.stringMatching(/required question/i), 3, 'validation_error', null); @@ -83,11 +198,12 @@ describe('rsvpAction questionnaire guard', () => { }); 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: [{ id: 'q1', text: 'Optional answer?', required: false }] }, + questionnaireVersions: [{ questions }], + questionnaire: { questions }, }, }); await rsvpAction('EV1', { name: 'Kaleb' }, mkCmd({ yes: true })); @@ -100,11 +216,12 @@ describe('rsvpAction questionnaire guard', () => { }); it('submits supplied answers inside questionnaireResponse', async () => { + const questions = [{ id: 'q1', text: 'Dietary restrictions?', required: true }]; routeApi({ event: { questionnaireEnabled: true, - questionnaireVersions: [{ questions: [] }], - questionnaire: { questions: [{ id: 'q1', text: 'Dietary restrictions?', required: true }] }, + questionnaireVersions: [{ questions }], + questionnaire: { questions }, }, }); await rsvpAction('EV1', { name: 'Kaleb', answer: ['q1=Vegan'] }, mkCmd({ yes: true })); @@ -118,10 +235,12 @@ describe('rsvpAction questionnaire guard', () => { }); it('refuses incomplete supplied answers and never calls addGuest', async () => { + const questions = [{ id: 'q1', text: 'Dietary restrictions?', required: true }]; routeApi({ event: { questionnaireEnabled: true, - questionnaire: { questions: [{ id: 'q1', text: 'Dietary restrictions?', required: true }] }, + questionnaireVersions: [{ questions }], + questionnaire: { questions }, }, }); await rsvpAction('EV1', { name: 'Kaleb', answer: ['other=value'] }, mkCmd({ yes: true })); diff --git a/tests/rsvp.test.js b/tests/rsvp.test.js index 0315961..7975c82 100644 --- a/tests/rsvp.test.js +++ b/tests/rsvp.test.js @@ -191,15 +191,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[]', () => { @@ -227,6 +226,92 @@ 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' }); @@ -241,6 +326,31 @@ describe('questionnaire (verified shape)', () => { 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(); }); @@ -270,6 +380,11 @@ describe('parseQuestionnaireAnswers', () => { 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); + }); }); describe('buildRsvpParams — count validation (Fix 1)', () => { @@ -322,22 +437,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' } }); From 58906a3ea378f29414ee3547b4606cbced4e84e2 Mon Sep 17 00:00:00 2001 From: Kaleb Cole Date: Fri, 31 Jul 2026 15:38:13 -0700 Subject: [PATCH 3/6] fix(rsvp): address review edge cases --- README.md | 4 ++- .../partiful/references/rsvps-and-interest.md | 2 +- src/commands/rsvp.ts | 7 +++++- src/commands/schema.ts | 2 +- src/lib/rsvp.ts | 9 ++++++- tests/rsvp-orchestration.test.js | 25 +++++++++++++++++++ tests/rsvp.test.js | 12 +++++++++ tests/schema-rsvp.test.js | 6 ++++- 8 files changed, 61 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index eb1d71e..ec1b9f4 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,9 @@ RSVP does a read-before-write: it updates your existing guest record if you already RSVP'd, otherwise creates 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. The same verbs are available under +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. The same verbs are available under `explore` (`partiful explore rsvp `) for the discovery flow. ### `guests` — Manage event guests diff --git a/skills/partiful/references/rsvps-and-interest.md b/skills/partiful/references/rsvps-and-interest.md index 4948617..39feba1 100644 --- a/skills/partiful/references/rsvps-and-interest.md +++ b/skills/partiful/references/rsvps-and-interest.md @@ -19,4 +19,4 @@ partiful events rsvp --answer "=" partiful events rsvp --answer "Dietary restrictions?=None" --answer "Song request?=Anything" ``` -Required answers are validated before submission. Ticketed or paid events remain unsupported because the CLI cannot purchase tickets. \ No newline at end of file +Required answers are validated before submission. 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 d741993..049068f 100644 --- a/src/commands/rsvp.ts +++ b/src/commands/rsvp.ts @@ -143,11 +143,16 @@ export async function rsvpAction(eventId: string, opts: Record, 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) ?? (currentGuest?.['status'] as string | undefined), + status: (opts['status'] as string | undefined) ?? reusableCurrentStatus, plusOnes, count, message, diff --git a/src/commands/schema.ts b/src/commands/schema.ts index 20a2b64..1e73262 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -19,7 +19,7 @@ interface CommandSchema { const RSVP_PARAMETERS: Record = { eventId: { type: 'string', required: true, positional: true, description: 'Event ID' }, - '--status': { type: 'string', required: false, default: 'going', description: 'RSVP status: going, maybe, declined' }, + '--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' }, diff --git a/src/lib/rsvp.ts b/src/lib/rsvp.ts index de4ce95..6ca142b 100644 --- a/src/lib/rsvp.ts +++ b/src/lib/rsvp.ts @@ -58,7 +58,7 @@ export interface QuestionnaireResponse { /** Parse repeatable `--answer key=value` options into a lookup map. */ export function parseQuestionnaireAnswers(pairs: string[] = []): Record { - const answers: Record = {}; + const answers: Record = Object.create(null); for (const pair of pairs) { const separator = pair.indexOf('='); if (separator < 0) { @@ -158,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), diff --git a/tests/rsvp-orchestration.test.js b/tests/rsvp-orchestration.test.js index adb05e2..d3a0c91 100644 --- a/tests/rsvp-orchestration.test.js +++ b/tests/rsvp-orchestration.test.js @@ -61,6 +61,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', () => { @@ -248,6 +263,16 @@ describe('rsvpAction questionnaire guard', () => { 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('rsvpAction confirmation gate', () => { diff --git a/tests/rsvp.test.js b/tests/rsvp.test.js index 7975c82..aeb7aca 100644 --- a/tests/rsvp.test.js +++ b/tests/rsvp.test.js @@ -89,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', @@ -385,6 +391,12 @@ describe('parseQuestionnaireAnswers', () => { 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)', () => { diff --git a/tests/schema-rsvp.test.js b/tests/schema-rsvp.test.js index e732bce..af5b1b1 100644 --- a/tests/schema-rsvp.test.js +++ b/tests/schema-rsvp.test.js @@ -16,7 +16,11 @@ describe('schema covers rsvp / interested verbs', () => { it('events.rsvp schema exposes status, plus-one, and questionnaire answer params', () => { const out = run(['schema', 'events.rsvp']); expect(out.data.command).toContain('rsvp'); - expect(out.data.parameters['--status']).toBeDefined(); + 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); From ec9e823bce5a31e2a68c78f6d5468692a6ddc712 Mon Sep 17 00:00:00 2001 From: Kaleb Cole Date: Fri, 7 Aug 2026 15:33:53 -0700 Subject: [PATCH 4/6] feat(rsvp): verify questionnaire answers via Firestore --- README.md | 11 +- .../partiful/references/rsvps-and-interest.md | 5 +- src/commands/rsvp.ts | 114 +++++++++++++++++- src/commands/schema.ts | 12 ++ src/lib/api/endpoints.ts | 9 ++ src/lib/http.ts | 30 +++++ tests/rsvp-orchestration.test.js | 108 ++++++++++++++++- tests/schema-api.test.js | 8 ++ tests/schema-rsvp.test.js | 14 ++- 9 files changed, 297 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index ec1b9f4..824a4fb 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ partiful events cancel ### `events rsvp` / `events interested` — RSVP to events ```bash +partiful events my-rsvp # Read your RSVP and questionnaire answers 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 @@ -109,14 +110,16 @@ 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 events are refused with a clear +`events my-rsvp` 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. The same verbs are available under -`explore` (`partiful explore rsvp `) for the discovery flow. +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 my-rsvp `) for the discovery flow. ### `guests` — Manage event guests diff --git a/skills/partiful/references/rsvps-and-interest.md b/skills/partiful/references/rsvps-and-interest.md index 39feba1..596eb2d 100644 --- a/skills/partiful/references/rsvps-and-interest.md +++ b/skills/partiful/references/rsvps-and-interest.md @@ -1,6 +1,7 @@ # RSVPs and Interest ```bash +partiful events my-rsvp partiful events rsvp --dry-run partiful events rsvp --status going partiful events rsvp --status going --plus-one "Alex Smith" @@ -10,7 +11,7 @@ partiful events interested partiful events interested --remove ``` -`explore rsvp` and `explore interested` are equivalent aliases. +`events my-rsvp` reads your saved status and questionnaire answers without changing the RSVP. `explore my-rsvp`, `explore rsvp`, and `explore interested` are equivalent aliases under the discovery command group. For questionnaire events, pass one repeatable answer per question. Keys may be the question ID or its exact text: @@ -19,4 +20,4 @@ partiful events rsvp --answer "=" partiful events rsvp --answer "Dietary restrictions?=None" --answer "Song request?=Anything" ``` -Required answers are validated before submission. 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 +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 049068f..5406347 100644 --- a/src/commands/rsvp.ts +++ b/src/commands/rsvp.ts @@ -12,7 +12,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'; @@ -45,6 +45,45 @@ 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 }; +} + +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). * @@ -56,7 +95,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); + } } /** @@ -181,13 +251,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 + : JSON.stringify(persistedQuestionnaireResponse) === JSON.stringify(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) { @@ -227,8 +327,14 @@ 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 + .command('my-rsvp') + .description('Read your RSVP status and questionnaire answers') + .argument('', 'Event ID') + .action(currentGuestAction); + parent .command('rsvp') .description('RSVP to an event (going, maybe, or declined)') diff --git a/src/commands/schema.ts b/src/commands/schema.ts index 1e73262..173237a 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -88,6 +88,12 @@ const SCHEMAS: Record = { command: 'events rsvp ', parameters: RSVP_PARAMETERS, }, + 'events.my-rsvp': { + command: 'events my-rsvp ', + parameters: { + eventId: { type: 'string', required: true, positional: true, description: 'Event ID' }, + }, + }, 'events.interested': { command: 'events interested ', parameters: { @@ -99,6 +105,12 @@ const SCHEMAS: Record = { command: 'explore rsvp ', parameters: RSVP_PARAMETERS, }, + 'explore.my-rsvp': { + command: 'explore my-rsvp ', + parameters: { + eventId: { type: 'string', required: true, positional: true, description: 'Event ID' }, + }, + }, 'explore.interested': { command: 'explore interested ', parameters: { diff --git a/src/lib/api/endpoints.ts b/src/lib/api/endpoints.ts index 36e9c6e..453b147 100644 --- a/src/lib/api/endpoints.ts +++ b/src/lib/api/endpoints.ts @@ -425,6 +425,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, @@ -474,6 +482,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 e2c7296..1fcd5c5 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -164,6 +164,36 @@ 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, + verbose = false, +): Promise { + const encodedPath = documentPath.split('/').map(encodeURIComponent).join('/'); + const fsPath = `/v1/projects/${FIRESTORE_PROJECT}/databases/(default)/documents/${encodedPath}`; + + const resp = await withRetry( + () => + fetch(`${FIRESTORE_BASE}${fsPath}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + Referer: 'https://partiful.com/', + }, + }), + verbose, + ); + + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + throw classifyError(resp.status, 'Firestore GET document failed', text); + } + + const text = await resp.text(); + return text ? JSON.parse(text) : {}; +} + export async function firestoreListDocuments( collectionPath: string, token: string, diff --git a/tests/rsvp-orchestration.test.js b/tests/rsvp-orchestration.test.js index d3a0c91..ddddca6 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(() => { @@ -125,6 +158,50 @@ describe('rsvpAction questionnaire guard', () => { 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('preserves the existing response after the host removes the questionnaire', async () => { const existingResponse = { questionnaireVersion: 0, @@ -275,6 +352,31 @@ describe('rsvpAction questionnaire guard', () => { }); }); +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', () => { it('aborts when the user declines and never calls addGuest', async () => { routeApi({}); diff --git a/tests/schema-api.test.js b/tests/schema-api.test.js index f775ce3..75c7ba9 100644 --- a/tests/schema-api.test.js +++ b/tests/schema-api.test.js @@ -40,6 +40,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 af5b1b1..904e682 100644 --- a/tests/schema-rsvp.test.js +++ b/tests/schema-rsvp.test.js @@ -5,14 +5,26 @@ 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.my-rsvp'); expect(out.data.commands).toContain('events.interested'); expect(out.data.commands).toContain('explore.rsvp'); + expect(out.data.commands).toContain('explore.my-rsvp'); expect(out.data.commands).toContain('explore.interested'); }); + it('events.my-rsvp exposes its event ID', () => { + const out = run(['schema', 'events.my-rsvp']); + expect(out.data.command).toBe('events my-rsvp '); + expect(out.data.parameters.eventId).toEqual(expect.objectContaining({ + type: 'string', + required: true, + positional: true, + })); + }); + it('events.rsvp schema exposes status, plus-one, and questionnaire answer params', () => { const out = run(['schema', 'events.rsvp']); expect(out.data.command).toContain('rsvp'); From e9f56e947462ffab51cfd6dc18e49d83fc6772c9 Mon Sep 17 00:00:00 2001 From: Kaleb Cole Date: Fri, 7 Aug 2026 16:25:43 -0700 Subject: [PATCH 5/6] Refine RSVP command namespace --- README.md | 16 +++++----- docs/explore-command-design.md | 17 +++++----- .../partiful/references/rsvps-and-interest.md | 18 +++++------ src/commands/rsvp.ts | 32 +++++++++++++++---- src/commands/schema.ts | 24 +++++++------- src/lib/http.ts | 2 ++ src/lib/rsvp.ts | 2 +- tests/http.test.js | 28 ++++++++++++++-- tests/rsvp-integration.test.js | 22 ++++++------- tests/rsvp-orchestration.test.js | 24 ++++++++++++++ tests/rsvp.test.js | 2 +- tests/schema-rsvp.test.js | 22 +++++++------ tests/skill-structure.test.js | 4 +-- 13 files changed, 142 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index 824a4fb..213135c 100644 --- a/README.md +++ b/README.md @@ -100,17 +100,17 @@ partiful events cancel ### `events rsvp` / `events interested` — RSVP to events ```bash -partiful events my-rsvp # Read your RSVP and questionnaire answers -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 --answer "=" # repeat per host question +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 ``` -`events my-rsvp` reads your saved status, guest details, and questionnaire +`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 @@ -119,7 +119,7 @@ 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 my-rsvp `) for the discovery flow. +`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..347b9e5 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,9 +57,9 @@ partiful explore regions [--format json|table] Lists region slugs + the tag list (id + friendly name) for --tag filtering. ``` -### `explore rsvp ` +### `explore rsvp set ` ``` -partiful explore rsvp [options] +partiful explore rsvp set [options] --status going | maybe | declined (default: going) --plus-one Add a named plus-one (repeatable) @@ -124,7 +125,7 @@ partiful explore interested [options] } ``` -### `explore rsvp` (success) +### `explore rsvp set` (success) ```json { "eventId": "JKQD5kibarjDeBw4LN6W", @@ -136,7 +137,7 @@ partiful explore interested [options] } ``` -### `explore rsvp` (refused — ticketed) +### `explore rsvp set` (refused — ticketed) ```json { "status": "error", @@ -153,7 +154,7 @@ 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 + run. `explore rsvp set` 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). 2. **`addGuest` payload** (built by `wrapPayload`, hidden from user): @@ -161,7 +162,7 @@ partiful explore interested [options] 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` / diff --git a/skills/partiful/references/rsvps-and-interest.md b/skills/partiful/references/rsvps-and-interest.md index 596eb2d..989dde8 100644 --- a/skills/partiful/references/rsvps-and-interest.md +++ b/skills/partiful/references/rsvps-and-interest.md @@ -1,23 +1,23 @@ # RSVPs and Interest ```bash -partiful events my-rsvp -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 ``` -`events my-rsvp` reads your saved status and questionnaire answers without changing the RSVP. `explore my-rsvp`, `explore rsvp`, and `explore interested` are equivalent aliases under the discovery command group. +`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. For questionnaire events, pass one repeatable answer per question. Keys may be the question ID or its exact text: ```bash -partiful events rsvp --answer "=" -partiful events rsvp --answer "Dietary restrictions?=None" --answer "Song request?=Anything" +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 5406347..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 @@ -71,6 +72,19 @@ function questionnaireResponseFromDocument(document: FirestoreGuestDocument): Qu 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, @@ -148,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 { @@ -276,7 +290,7 @@ export async function rsvpAction(eventId: string, opts: Record, status: persistedStatus === params.rsvp.status, questionnaireResponse: expectedQuestionnaireResponse === null ? null - : JSON.stringify(persistedQuestionnaireResponse) === JSON.stringify(expectedQuestionnaireResponse), + : questionnaireResponsesEqual(persistedQuestionnaireResponse, expectedQuestionnaireResponse), }; jsonOutput({ @@ -329,14 +343,18 @@ async function interestedAction(eventId: string, opts: Record, /** Attach RSVP/interest subcommands to a parent command (events or explore). */ function attachRsvpVerbs(parent: Command): void { - parent - .command('my-rsvp') + 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); - parent - .command('rsvp') + rsvp + .command('set') .description('RSVP to an event (going, maybe, or declined)') .argument('', 'Event ID') .option('--status ', `RSVP status: ${RSVP_STATUSES.join(', ')}`) diff --git a/src/commands/schema.ts b/src/commands/schema.ts index 173237a..ba047ed 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -84,16 +84,16 @@ const SCHEMAS: Record = { eventId: { type: 'string', required: true, positional: true }, }, }, - 'events.rsvp': { - command: 'events rsvp ', - parameters: RSVP_PARAMETERS, - }, - 'events.my-rsvp': { - command: 'events my-rsvp ', + 'events.rsvp.get': { + command: 'events rsvp get ', parameters: { eventId: { type: 'string', required: true, positional: true, description: 'Event ID' }, }, }, + 'events.rsvp.set': { + command: 'events rsvp set ', + parameters: RSVP_PARAMETERS, + }, 'events.interested': { command: 'events interested ', parameters: { @@ -101,16 +101,16 @@ const SCHEMAS: Record = { '--remove': { type: 'boolean', required: false, description: 'Remove interest instead of adding it' }, }, }, - 'explore.rsvp': { - command: 'explore rsvp ', - parameters: RSVP_PARAMETERS, - }, - 'explore.my-rsvp': { - command: 'explore my-rsvp ', + 'explore.rsvp.get': { + command: 'explore rsvp get ', parameters: { eventId: { type: 'string', required: true, positional: true, description: 'Event ID' }, }, }, + 'explore.rsvp.set': { + command: 'explore rsvp set ', + parameters: RSVP_PARAMETERS, + }, 'explore.interested': { command: 'explore interested ', parameters: { diff --git a/src/lib/http.ts b/src/lib/http.ts index 1fcd5c5..1d12013 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). @@ -177,6 +178,7 @@ export async function firestoreGetDocument( () => 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 6ca142b..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. diff --git a/tests/http.test.js b/tests/http.test.js index 07f41c7..7ae489a 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 } 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', () => { @@ -11,4 +20,19 @@ describe('http module exports', () => { it('exports firestoreListDocuments as function', () => { expect(typeof firestoreListDocuments).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({ signal: expect.any(AbortSignal) }), + ); + }); }); 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 ddddca6..b1d37df 100644 --- a/tests/rsvp-orchestration.test.js +++ b/tests/rsvp-orchestration.test.js @@ -202,6 +202,30 @@ describe('rsvpAction questionnaire guard', () => { 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, diff --git a/tests/rsvp.test.js b/tests/rsvp.test.js index aeb7aca..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. */ diff --git a/tests/schema-rsvp.test.js b/tests/schema-rsvp.test.js index 904e682..b25288d 100644 --- a/tests/schema-rsvp.test.js +++ b/tests/schema-rsvp.test.js @@ -7,17 +7,19 @@ import { run } from './helpers.js'; describe('schema covers rsvp / interested verbs', () => { 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.my-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.my-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.my-rsvp exposes its event ID', () => { - const out = run(['schema', 'events.my-rsvp']); - expect(out.data.command).toBe('events my-rsvp '); + 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, @@ -25,9 +27,9 @@ describe('schema covers rsvp / interested verbs', () => { })); }); - it('events.rsvp schema exposes status, plus-one, and questionnaire answer params', () => { - const out = run(['schema', 'events.rsvp']); - expect(out.data.command).toContain('rsvp'); + 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), 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'); }); From 39a653349b8be27cb3716d80817ab8d03b951c49 Mon Sep 17 00:00:00 2001 From: Kaleb Cole Date: Fri, 7 Aug 2026 16:41:37 -0700 Subject: [PATCH 6/6] docs: align RSVP design and request test --- docs/explore-command-design.md | 32 +++++++++++++++++++------------- tests/http.test.js | 9 ++++++++- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/docs/explore-command-design.md b/docs/explore-command-design.md index 347b9e5..ddfe652 100644 --- a/docs/explore-command-design.md +++ b/docs/explore-command-design.md @@ -58,18 +58,23 @@ partiful explore regions [--format json|table] ``` ### `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 ` @@ -153,10 +158,11 @@ 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 set` 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`; @@ -165,9 +171,9 @@ partiful explore interested [options] 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/tests/http.test.js b/tests/http.test.js index 00f3266..f6b32bc 100644 --- a/tests/http.test.js +++ b/tests/http.test.js @@ -35,7 +35,14 @@ describe('http module exports', () => { expect(fetchMock).toHaveBeenCalledWith( expect.any(String), - expect.objectContaining({ signal: expect.any(AbortSignal) }), + expect.objectContaining({ + method: 'GET', + signal: expect.any(AbortSignal), + headers: expect.objectContaining({ + Authorization: 'Bearer token', + Referer: 'https://partiful.com/', + }), + }), ); }); });