diff --git a/docs/plans/2026-08-01-cohost-lifecycle-and-links.md b/docs/plans/2026-08-01-cohost-lifecycle-and-links.md new file mode 100644 index 0000000..7edf2c5 --- /dev/null +++ b/docs/plans/2026-08-01-cohost-lifecycle-and-links.md @@ -0,0 +1,210 @@ +# Cohost Lifecycle and Invite Links Implementation Plan + +> **For Hermes:** Use strict red-green-refactor for every behavior-changing task. + +**Goal:** Replace false-success Firestore writes with Partiful's canonical cohost request lifecycle, add invite-link lifecycle commands, and expose accurate state through CLI/schema/skill docs. + +**Architecture:** Put canonical cohost operations and state normalization in `src/lib/cohosts.ts`; keep Commander handlers in `src/commands/cohosts.ts` thin. Direct add calls Firebase callable `createCohostRequest`; remove routes to `deleteCohostRequest` or `removeCohost` based on request state. Link inspection reads `events/{eventId}/private/cohostSecret`, while enable/disable call `generateEventCohostLink` and `revokeEventCohostLink`. Never PATCH `cohostIds` for normal lifecycle transitions. Legacy `cohostIds`-only corruption is the narrow exception: clear the stale ID first because both canonical add/remove endpoints return `INTERNAL`, then create the canonical request. + +**Tech Stack:** TypeScript, Commander, Firebase callable HTTP, Firestore REST, Zod, Vitest. + +--- + +## Canonical API discovery + +Production Next.js bundle and live reversible probes establish: + +| Operation | Canonical endpoint/state | Params/result | +|---|---|---| +| Invite direct contact | `POST /createCohostRequest` | `params: { eventId, targetUserId }` | +| Accept/decline invite | `POST /updateCohostRequestStatus` | `params: { eventId, status }`, status `ACCEPTED` or `DECLINED` | +| Delete pending request | `POST /deleteCohostRequest` | `params: { eventId, targetUserId }` | +| Remove accepted cohost | `POST /removeCohost` | `params: { eventId, targetUserId }` | +| List lifecycle state | Firestore `events/{eventId}/cohostRequests` | docs keyed by cohost user ID; `status` is `PENDING`, `ACCEPTED`, or `DECLINED` | +| Inspect invite link | Firestore `events/{eventId}/private/cohostSecret` | fields: `path`, `createdAt`, `createdBy`; absent means disabled | +| Enable/rotate link | `POST /generateEventCohostLink` | `params: { eventId }`; result contains `path` | +| Disable link | `POST /revokeEventCohostLink` | `params: { eventId }` | +| Accept link | Event URL query | server-generated `/e/{eventId}?accept-cohost={uuid}`; UI validates secret and calls acceptance lifecycle | + +Live probe generated a real link, verified the private Firestore document and field shape, then revoked it. Post-revoke state returned to disabled. + +## Behavioral contract + +1. `cohosts add` resolves every requested name. Exact matching wins; duplicate exact or ambiguous partial matches fail closed with candidates; misses fail the command. +2. Direct add always calls `createCohostRequest`. For legacy `cohostIds`-only state, repair first removes the corrupt raw ID, then calls the canonical endpoint. Live verification showed calling either canonical endpoint before cleanup returns `INTERNAL`. +3. Existing `PENDING` or `ACCEPTED` request is a no-op. `DECLINED` is re-invited through `createCohostRequest`. +4. `cohosts list` merges request docs with legacy `cohostIds`. Request docs report lowercase `pending`, `accepted`, or `declined`; IDs only in `cohostIds` report `stale`. +5. `cohosts remove` uses `deleteCohostRequest` for pending/declined and `removeCohost` for accepted membership. Stale membership is removed through the explicit legacy-repair Firestore path because the canonical endpoint cannot process it. +6. `cohosts link ` inspects without mutation. `--enable` returns an existing URL as a no-op or generates one. `--disable` revokes an existing link or no-ops when absent. `--enable` and `--disable` are mutually exclusive. +7. Global `--dry-run` may perform authenticated reads/resolution but no writes. Output states intended endpoint/action. +8. Link output returns the URL only; delivery stays with approved messaging tools. + +### Task 1: Add typed API specs + +**Files:** +- Modify: `src/lib/api/endpoints.ts` +- Modify: `tests/api-spec.test.js` + +**RED:** Add assertions that endpoint registry exposes the five lifecycle callables with exact request params. + +**Verify RED:** `npm test -- --run tests/api-spec.test.js` fails because methods are absent. + +**GREEN:** Add request interfaces, permissive response schemas, endpoint metadata, and response-schema registry entries for: +- `createCohostRequest(eventId, targetUserId)` +- `deleteCohostRequest(eventId, targetUserId)` +- `removeCohost(eventId, targetUserId)` +- `generateEventCohostLink(eventId)` +- `revokeEventCohostLink(eventId)` + +**Verify GREEN:** targeted test and `npm run typecheck` pass. + +### Task 2: Make contact resolution fail closed + +**Files:** +- Create: `tests/cohosts.test.js` +- Modify: `src/lib/cohosts.ts` + +**RED:** Test exact match, unique partial match, ambiguous partial candidates, duplicate exact names, unresolved names, and deduped input. + +**Verify RED:** targeted tests fail against current first-substring/warn-and-skip behavior. + +**GREEN:** Introduce typed `CohostResolutionError` or `PartifulError` details with query/candidates. Return resolved IDs only when all requested names resolve uniquely. Keep contact fetching injectable or split pure `resolveContactNames(names, contacts)` from transport. + +**Verify GREEN:** targeted tests and typecheck pass. + +### Task 3: Normalize request and stale state + +**Files:** +- Modify: `tests/cohosts.test.js` +- Modify: `src/lib/cohosts.ts` +- Modify: `src/lib/http.ts` + +**RED:** Test Firestore typed-value parsing for request docs, accepted/pending/declined normalization, merge with `cohostIds`, and `stale` classification. + +**Verify RED:** tests fail because request-state readers do not exist. + +**GREEN:** Add generic authenticated Firestore document GET helper and cohost request listing. Add pure merge function. Preserve unknown server fields but only emit stable CLI fields. + +**Verify GREEN:** targeted tests and typecheck pass. + +### Task 4: Implement direct invite orchestration + +**Files:** +- Modify: `tests/cohosts.test.js` +- Modify: `src/lib/cohosts.ts` +- Modify: `src/commands/cohosts.ts` + +**RED:** With injected transport, test: +- new target calls `/createCohostRequest`; +- stale `cohostIds`-only target still calls endpoint; +- pending and accepted requests no-op; +- declined request re-invites; +- multiple targets produce per-target outcomes; +- dry-run performs no mutation; +- partial failure is surfaced, never reported as blanket success. + +**Verify RED:** tests fail because command still PATCHes `cohostIds`. + +**GREEN:** Add `inviteCohost`/`inviteCohosts` orchestration and switch command from `setCohostIds` to the callable. Remove `setCohostIds` from command paths. Return `invited`, `pending`, `accepted`, `reinvited`, and `stale_repair` outcomes. + +**Verify GREEN:** targeted tests, integration dry-runs, and typecheck pass. + +### Task 5: Implement canonical remove routing + +**Files:** +- Modify: `tests/cohosts.test.js` +- Modify: `src/lib/cohosts.ts` +- Modify: `src/commands/cohosts.ts` + +**RED:** Test pending/declined route to `/deleteCohostRequest`, accepted/stale route to `/removeCohost`, missing target fails not-found, and dry-run is read-only. + +**Verify RED:** tests fail because current command PATCHes `cohostIds`. + +**GREEN:** Add removal planner/executor and update handler/output. + +**Verify GREEN:** targeted tests and full suite pass. + +### Task 6: Implement link inspect/enable/disable + +**Files:** +- Modify: `tests/cohosts.test.js` +- Modify: `src/lib/cohosts.ts` +- Modify: `src/commands/cohosts.ts` + +**RED:** Test missing document = disabled, path conversion to absolute URL, inspect read-only, enable existing = no-op, enable absent calls generation, disable existing calls revoke, disable absent = no-op, conflicting flags fail, and dry-run returns planned action without mutation. + +**Verify RED:** tests fail because `cohosts link` does not exist. + +**GREEN:** Add `getCohostLink`, `generateCohostLink`, `revokeCohostLink`, path validation, and Commander subcommand. Never synthesize token paths. + +**Verify GREEN:** targeted tests, CLI `--help`, schema tests, and typecheck pass. + +### Task 7: Route event create/update cohosts canonically + +**Files:** +- Modify: `tests/events-integration.test.js` +- Modify: `src/commands/events.ts` +- Modify: `src/lib/cohosts.ts` + +**RED:** Test create dry-run reports empty `createEvent.cohostIds` plus planned post-create requests; update dry-run reports request actions rather than a raw Firestore `cohostIds` update. + +**Verify RED:** existing output uses raw IDs/writes. + +**GREEN:** Create event first without directly assigning cohosts, then issue canonical requests after receiving event ID. Route update `--cohost` through same inviter. If event creation succeeds but any invite fails, return explicit partial-success details including event URL and failed target. + +**Verify GREEN:** targeted event tests and full suite pass. + +### Task 8: Update schema, help, and skill routing + +**Files:** +- Modify: `src/commands/schema.ts` +- Modify: `tests/events-integration.test.js` +- Modify: `skills/partiful/SKILL.md` +- Modify: `skills/partiful/references/guests-invitations-and-cohosts.md` +- Modify: event-management reference(s) discovered in `skills/partiful/references/` + +**RED:** Add schema assertions for changed `cohosts.add/remove` and new `cohosts.link`, plus new API methods. + +**Verify RED:** tests fail on absent schema surfaces. + +**GREEN:** Document direct invitation as approval-required external action; link creation as reversible state change; link sending as a separate approval-gated messaging action. Route cohost asks from both event-management and guest/invitation sections. Update create/update examples. + +**Verify GREEN:** schema tests and skill validator pass. + +### Task 9: Full automated verification + +Run: + +```bash +npm test +npm run typecheck +npm run build +npm run lint --if-present +``` + +Expected: all commands exit 0 with pristine output. Also run CLI smoke checks for `cohosts --help`, `cohosts link --help`, command schemas, and API schemas. + +### Task 10: Live end-to-end verification + +1. Inspect initial request/link state on a disposable event. +2. Run direct add to the approved second account and verify a `PENDING` request doc plus recipient invitation. +3. Accept from second-account UI; verify request becomes `ACCEPTED`, ID appears in event host state, and host controls are visible. +4. Remove through CLI; verify host controls disappear and lifecycle state is removed. +5. Enable link; verify returned URL matches stored private `path` without exposing it in logs beyond intended CLI output. +6. Open/accept with second account; verify accepted host state. +7. Disable link; verify secret document absent. +8. Restore disposable event/account state. + +Do not send links or invitations to a third party without explicit authorization. + +### Task 11: Adversarial review and remediation + +Dispatch an independent adversarial reviewer against issue #74, this plan, and the branch diff. Require verdict, severity-ranked findings, missing tests, security/privacy concerns, and exact evidence. Reproduce every credible finding, add a failing regression test, fix, and rerun all gates. Repeat review if material changes result. + +### Task 12: Commit and open PR + +1. Review diff and ensure no captured secrets/tokens/build artifacts. +2. Commit coherent changes with issue reference. +3. Push `fix/cohost-lifecycle-links`. +4. Open PR with problem, canonical lifecycle evidence, test matrix, live verification evidence, and `Fixes #74`. +5. Verify CI checks and review-bot surfaces; fix failures before reporting completion. diff --git a/skills/partiful/SKILL.md b/skills/partiful/SKILL.md index cf4e11d..36b20c9 100644 --- a/skills/partiful/SKILL.md +++ b/skills/partiful/SKILL.md @@ -20,7 +20,7 @@ Operate Partiful through the JSON-first `partiful` CLI. Load only the reference |---|---| | Login, auth status, credential resolution, or authentication diagnostics | [Authentication](references/authentication.md) | | Output formats, global flags, schema discovery, errors, or cross-command safety | [CLI output and safety](references/cli-output-and-safety.md) | -| List, inspect, create, update, cancel, clone, template, or bulk-manage events | [Events](references/events.md) | +| List, inspect, create, update, cancel, clone, template, bulk-manage events, or add cohosts during event creation/update | [Events](references/events.md) | | RSVP to an event or express interest as an attendee | [RSVPs and interest](references/rsvps-and-interest.md) | | List/export/watch guests, invite people, find contacts, or manage cohosts as a host | [Guests, invitations, and cohosts](references/guests-invitations-and-cohosts.md) | | Browse posters, select event imagery, or upload a custom image | [Posters and images](references/posters-and-images.md) | diff --git a/skills/partiful/references/events.md b/skills/partiful/references/events.md index 94ccfe8..c48d7f3 100644 --- a/skills/partiful/references/events.md +++ b/skills/partiful/references/events.md @@ -24,6 +24,16 @@ partiful events create \ `--title` and `--date` are required unless supplied by a template. Common options: `--end-date`, `--address`, `--capacity`, `--private`, `--theme`, `--effect`, `--poster`, `--poster-search`, `--image`, `--link`, `--link-text`, `--cohost`, `--template`, and `--var`. +Add cohosts by unique Partiful contact name. This previews a post-create canonical cohost request, not a direct `cohostIds` write: + +```bash +partiful events create --title "Game Night" --date "2026-08-01T19:00" \ + --cohost "Alex Smith" --dry-run +partiful events update --cohost "Alex Smith" --dry-run +``` + +Get approval before executing because the request notifies another person and grants event controls after acceptance. See [Guests, invitations, and cohosts](guests-invitations-and-cohosts.md) for direct-request states and invite-link workflows. + Dates must include a full year. The default timezone is `America/Los_Angeles`. Descriptions are plain text, not Markdown. ## Update and Cancel diff --git a/skills/partiful/references/guests-invitations-and-cohosts.md b/skills/partiful/references/guests-invitations-and-cohosts.md index ca20a7e..49a4723 100644 --- a/skills/partiful/references/guests-invitations-and-cohosts.md +++ b/skills/partiful/references/guests-invitations-and-cohosts.md @@ -21,7 +21,7 @@ partiful guests invite --user-id --dry-run partiful guests invite --user-id --message "Hope you can make it" ``` -There is no direct `--name` invite. Resolve a name first: +There is no direct `--name` guest invite. Resolve a name first: ```bash partiful contacts list "Alex" @@ -30,7 +30,7 @@ partiful guests invite --user-id --dry-run Contacts return names, IDs, and shared-event counts. They do not expose email addresses or phone numbers. Get approval before sending invites, especially in bulk. -## Cohosts +## Cohost Direct Invitations ```bash partiful cohosts list @@ -39,8 +39,41 @@ partiful cohosts add --user-id --dry-run partiful cohosts remove --user-id --dry-run ``` -Resolve ambiguous contact names before adding cohosts. Verify the event and proposed changes, then get approval before adding or removing cohosts. +`cohosts add` uses Partiful's canonical `createCohostRequest` lifecycle. It does not write `cohostIds` directly for normal transitions. The recipient remains `pending` until accepting; `cohosts list` distinguishes `pending`, `accepted`, `declined`, and legacy `stale` membership. Re-running add repairs stale `cohostIds`-only corruption by clearing that legacy ID, then issuing the missing canonical request. + +Name resolution fails closed. Exact matches win; a partial name is accepted only when unique. Ambiguous or unresolved names fail the command and ambiguous errors include candidates. Do not work around that guard by picking the first result. + +Adding or removing a cohost affects another person and their event controls. Preview with `--dry-run`; get approval unless the user's request already explicitly authorizes the named action. + +## Cohost Invite Links + +```bash +# Inspect current state, read-only +partiful cohosts link + +# Preview and enable/create +partiful cohosts link --enable --dry-run +partiful cohosts link --enable + +# Preview and revoke +partiful cohosts link --disable --dry-run +partiful cohosts link --disable +``` + +Enable returns the complete URL generated by Partiful, such as `https://partiful.com/e/?accept-cohost=`. The CLI returns the link but does not send it. Sending through iMessage, email, or another channel is a separate external action and needs the normal outbound-message approval. + +Treat the URL as a capability secret. Do not put it in logs, issue bodies, screenshots, or public channels. Disable it after use when continued access is unnecessary. `--enable` is a no-op when an active link already exists; `--disable` is a no-op when disabled. `--enable` and `--disable` are mutually exclusive. + +## Cohosts During Event Create or Update + +```bash +partiful events create --title "Game Night" --date "2026-08-01T19:00" \ + --cohost "Alex Smith" --dry-run +partiful events update --cohost "Alex Smith" --dry-run +``` + +Create first sends `createEvent` with an empty direct-membership list, then issues canonical cohost requests after Partiful returns the event ID. Update routes `--cohost` through the same request lifecycle. Inspect `cohostInvites` in dry-run/output rather than expecting `cohostIds` in a Firestore update. ## Privacy -Phone numbers and Partiful user IDs may be needed as command inputs. Do not echo them in user-facing completion messages or logs. \ No newline at end of file +Phone numbers, user IDs, guest lists, and cohost invite-link secrets are sensitive. Do not echo them in user-facing completion messages or unrelated logs. diff --git a/src/commands/cohosts.ts b/src/commands/cohosts.ts index 4ae8ea6..4883f61 100644 --- a/src/commands/cohosts.ts +++ b/src/commands/cohosts.ts @@ -1,135 +1,227 @@ -/** - * Cohosts commands: list, add, remove - */ +/** Cohost commands backed by Partiful's canonical request/link lifecycle. */ import { Command } from 'commander'; import { loadConfig, getValidToken, wrapPayload } from '../lib/auth.js'; +import type { PartifulConfig } from '../lib/auth.js'; import { apiRequest } from '../lib/http.js'; -import { resolveCohostNames, getCohostIds, setCohostIds } from '../lib/cohosts.js'; +import { + resolveCohostNames, + getContacts, + getCohostState, + getCohostRequests, + getCohostIds, + mergeCohostState, + setCohostIds, + inviteCohost, + removeCohostCanonical, + planCohostInvite, + planCohostRemoval, + getCohostLink, + planCohostLinkAction, + cohostPathToUrl, +} from '../lib/cohosts.js'; +import type { CanonicalCohostCall, CohostLinkRequest } from '../lib/cohosts.js'; import { jsonOutput, jsonError } from '../lib/output.js'; -import { PartifulError } from '../lib/errors.js'; +import { ApiError, PartifulError, ValidationError } from '../lib/errors.js'; + +function callable( + token: string, + config: PartifulConfig, + verbose = false, +): CanonicalCohostCall { + return async (endpoint, params) => apiRequest('POST', endpoint, token, { + data: wrapPayload(config, { + params, + amplitudeSessionId: Date.now(), + userId: config.userId, + }), + }, verbose); +} + +function reportError(error: unknown): void { + if (error instanceof PartifulError) jsonError(error.message, error.exitCode, error.type, error.details); + else jsonError(error instanceof Error ? error.message : String(error)); +} export function registerCohostsCommands(program: Command): void { const cohosts = program.command('cohosts').description('Manage event co-hosts'); cohosts .command('list') - .description('List co-hosts for an event') + .description('List co-host invitations and accepted co-hosts') .argument('', 'Event ID') - .action(async (eventId: string, opts: Record, cmd: Command) => { + .action(async (eventId: string, _opts: Record, cmd: Command) => { const globalOpts = cmd.optsWithGlobals>(); try { const config = loadConfig(); const token = await getValidToken(config); - - const ids = await getCohostIds(eventId, token, globalOpts['verbose'] as boolean | undefined); - if (ids.length === 0) { - jsonOutput([], { eventId, count: 0 }); - return; - } - - // Cross-reference with contacts for names - const contactsPayload = { data: wrapPayload(config, { params: {}, amplitudeSessionId: Date.now(), userId: config.userId }) }; - const contactsRaw = await apiRequest('POST', '/getContacts', token, contactsPayload, globalOpts['verbose'] as boolean | undefined); - const contactsResult = contactsRaw as Record; - const resultData = contactsResult['result'] as Record | undefined; - const allContacts = (resultData?.['data'] ?? []) as Array>; - - const result = ids.map((id: string) => { - const contact = allContacts.find((c) => c['userId'] === id); - return { userId: id, name: (contact?.['name'] as string | undefined) ?? null }; - }); - + const verbose = globalOpts['verbose'] as boolean | undefined; + const [states, contacts] = await Promise.all([ + getCohostState(eventId, token, verbose), + getContacts(token, config, verbose), + ]); + const result = states.map((state) => ({ + ...state, + name: contacts.find((contact) => contact.userId === state.userId)?.name ?? null, + })); jsonOutput(result, { eventId, count: result.length }); - } catch (e) { - if (e instanceof PartifulError) jsonError(e.message, e.exitCode, e.type, e.details); - else jsonError(e instanceof Error ? e.message : String(e)); + } catch (error) { + reportError(error); } }); cohosts .command('add') - .description('Add co-hosts to an event') + .description('Invite co-hosts through Partiful’s canonical request lifecycle') .argument('', 'Event ID') - .option('--name ', 'Co-host names (resolved from contacts)') - .option('--user-id ', 'Co-host user IDs (direct)') + .option('--name ', 'Co-host names (must resolve uniquely from contacts)') + .option('--user-id ', 'Partiful user IDs') .action(async (eventId: string, opts: Record, cmd: Command) => { const globalOpts = cmd.optsWithGlobals>(); try { if (!opts['name'] && !opts['userId']) { - jsonError('Provide --name or --user-id to specify co-hosts', 3, 'validation_error'); - return; + throw new ValidationError('Provide --name or --user-id to specify co-hosts'); } - const config = loadConfig(); const token = await getValidToken(config); - - const currentIds = await getCohostIds(eventId, token, globalOpts['verbose'] as boolean | undefined); - const newIds = [...currentIds]; - - // Resolve names - const resolved = await resolveCohostNames((opts['name'] as string[] | undefined) ?? [], token, config, globalOpts['verbose'] as boolean | undefined); - for (const id of resolved) { - if (!newIds.includes(id)) newIds.push(id); - } - - // Add direct user IDs - for (const id of ((opts['userId'] as string[] | undefined) ?? [])) { - if (!newIds.includes(id)) newIds.push(id); - } - - const added = newIds.filter((id) => !currentIds.includes(id)); - if (added.length === 0) { - jsonOutput({ eventId, added: [], total: currentIds.length, message: 'No new co-hosts to add' }); - return; - } + const verbose = globalOpts['verbose'] as boolean | undefined; + const [requests, resolved, existingIds] = await Promise.all([ + getCohostRequests(eventId, token, verbose), + resolveCohostNames((opts['name'] as string[] | undefined) ?? [], token, config, verbose), + getCohostIds(eventId, token, verbose), + ]); + const states = mergeCohostState(requests, existingIds); + let currentIds = existingIds; + const ids = [...new Set([...resolved, ...((opts['userId'] as string[] | undefined) ?? [])].filter(Boolean))]; + const plans = ids.map((userId) => { + const state = states.find((item) => item.userId === userId); + const action = planCohostInvite(state); + return { + userId, + action, + endpoints: action === 'noop' + ? [] + : action === 'repair' + ? ['Firestore PATCH cohostIds (remove stale ID)', '/createCohostRequest'] + : ['/createCohostRequest'], + currentStatus: state?.status ?? null, + }; + }); if (globalOpts['dryRun']) { - jsonOutput({ dryRun: true, eventId, currentCohosts: currentIds, newCohosts: newIds }); + jsonOutput({ dryRun: true, eventId, plans }); return; } - await setCohostIds(eventId, newIds, token, globalOpts['verbose'] as boolean | undefined); - - jsonOutput({ eventId, added, total: newIds.length, url: `https://partiful.com/e/${eventId}` }); - } catch (e) { - if (e instanceof PartifulError) jsonError(e.message, e.exitCode, e.type, e.details); - else jsonError(e instanceof Error ? e.message : String(e)); + const call = callable(token, config, verbose); + const succeeded: Array<{ userId: string; outcome: string }> = []; + const failed: Array<{ userId: string; error: string }> = []; + for (const userId of ids) { + const state = states.find((item) => item.userId === userId); + const repairStale = state?.status === 'stale' + ? async () => { + currentIds = currentIds.filter((id) => id !== userId); + await setCohostIds(eventId, currentIds, token, verbose); + } + : undefined; + try { + succeeded.push(await inviteCohost(eventId, userId, state, call, repairStale)); + } catch (error) { + failed.push({ userId, error: String(error) }); + } + } + if (failed.length > 0) { + throw new ApiError('One or more co-host invitations failed', { eventId, succeeded, failed }); + } + jsonOutput({ eventId, results: succeeded, url: `https://partiful.com/e/${eventId}` }); + } catch (error) { + reportError(error); } }); cohosts .command('remove') - .description('Remove a co-host from an event') + .description('Remove a request or accepted co-host through the canonical lifecycle') .argument('', 'Event ID') - .requiredOption('--user-id ', 'User ID of the co-host to remove') + .requiredOption('--user-id ', 'Partiful user ID') .action(async (eventId: string, opts: Record, cmd: Command) => { const globalOpts = cmd.optsWithGlobals>(); try { const config = loadConfig(); const token = await getValidToken(config); - - const currentIds = await getCohostIds(eventId, token, globalOpts['verbose'] as boolean | undefined); + const verbose = globalOpts['verbose'] as boolean | undefined; const userId = opts['userId'] as string; - - if (!currentIds.includes(userId)) { - jsonError(`User ${userId} is not a co-host of this event`, 4, 'not_found'); + const [states, currentIds] = await Promise.all([ + getCohostState(eventId, token, verbose), + getCohostIds(eventId, token, verbose), + ]); + const state = states.find((item) => item.userId === userId); + const action = planCohostRemoval(state); + const endpoint = state?.status === 'stale' + ? 'Firestore PATCH cohostIds (remove stale ID)' + : action === 'delete_request' ? '/deleteCohostRequest' : '/removeCohost'; + if (globalOpts['dryRun']) { + jsonOutput({ dryRun: true, eventId, userId, currentStatus: state?.status ?? 'unknown', action, endpoint }); return; } + const removeStale = state?.status === 'stale' + ? () => setCohostIds(eventId, currentIds.filter((id) => id !== userId), token, verbose) + : undefined; + const result = await removeCohostCanonical( + eventId, + state, + callable(token, config, verbose), + removeStale, + ); + jsonOutput({ eventId, ...result, url: `https://partiful.com/e/${eventId}` }); + } catch (error) { + reportError(error); + } + }); - const newIds = currentIds.filter((id) => id !== userId); + cohosts + .command('link') + .description('Inspect, enable, or disable the co-host invite link') + .argument('', 'Event ID') + .option('--enable', 'Enable/create the co-host invite link') + .option('--disable', 'Disable/revoke the co-host invite link') + .action(async (eventId: string, opts: Record, cmd: Command) => { + const globalOpts = cmd.optsWithGlobals>(); + try { + if (opts['enable'] && opts['disable']) { + throw new ValidationError('--enable and --disable are mutually exclusive'); + } + const requested: CohostLinkRequest = opts['enable'] ? 'enable' : opts['disable'] ? 'disable' : 'inspect'; + const config = loadConfig(); + const token = await getValidToken(config); + const verbose = globalOpts['verbose'] as boolean | undefined; + const current = await getCohostLink(eventId, token, verbose); + const action = planCohostLinkAction(requested, current.enabled); if (globalOpts['dryRun']) { - jsonOutput({ dryRun: true, eventId, removing: userId, remaining: newIds }); + jsonOutput({ dryRun: true, eventId, requested, action, ...current }); + return; + } + if (action === 'inspect' || action === 'noop') { + jsonOutput({ eventId, action, ...current }); return; } - await setCohostIds(eventId, newIds, token, globalOpts['verbose'] as boolean | undefined); + const call = callable(token, config, verbose); + if (action === 'revoke') { + await call('/revokeEventCohostLink', { eventId }); + jsonOutput({ eventId, action: 'revoked', enabled: false, url: null }); + return; + } - jsonOutput({ eventId, removed: userId, remaining: newIds.length, url: `https://partiful.com/e/${eventId}` }); - } catch (e) { - if (e instanceof PartifulError) jsonError(e.message, e.exitCode, e.type, e.details); - else jsonError(e instanceof Error ? e.message : String(e)); + const raw = await call('/generateEventCohostLink', { eventId }) as { + result?: { data?: { path?: string } }; + }; + const path = raw.result?.data?.path; + if (!path) throw new ApiError('Partiful did not return a co-host invite-link path'); + jsonOutput({ eventId, action: 'generated', enabled: true, url: cohostPathToUrl(path) }); + } catch (error) { + reportError(error); } }); } diff --git a/src/commands/events.ts b/src/commands/events.ts index 256743b..bc53739 100644 --- a/src/commands/events.ts +++ b/src/commands/events.ts @@ -6,7 +6,7 @@ import type { Command } from 'commander'; import type { EventOptions } from '../lib/events.js'; import type { Template } from '../lib/templates.js'; import { loadConfig, getValidToken, wrapPayload, getUserIdFromToken } from '../lib/auth.js'; -import { resolveCohostNames } from '../lib/cohosts.js'; +import { resolveCohostNames, getCohostRequests, getCohostIds, mergeCohostState, setCohostIds, inviteCohostBatch } from '../lib/cohosts.js'; import { fetchCatalog, searchPosters, buildPosterImage } from '../lib/posters.js'; import { apiRequest, firestoreRequest } from '../lib/http.js'; import { parseDateTime, stripMarkdown } from '../lib/dates.js'; @@ -31,6 +31,14 @@ function makePayload(config: ReturnType, params: Record, + verbose?: boolean, +): (endpoint: string, params: Record) => Promise { + return (endpoint, params) => apiRequest('POST', endpoint, token, makePayload(config, params), verbose); +} + /** * Standard error handler for action callbacks. */ @@ -222,11 +230,16 @@ export function registerEventsCommands(program: Command): void { } const cohostIds = await resolveCohostNames((opts['cohost'] as string[] | undefined) ?? [], token, config, globalOpts['verbose'] as boolean | undefined); - - const payload = makePayload(config, { event, cohostIds }); + // Cohosts are invited only after creation through createCohostRequest. + // Passing IDs directly to createEvent reproduces the stale-membership bug. + const payload = makePayload(config, { event, cohostIds: [] }); + const cohostInvites = cohostIds.map((cohostId) => ({ + endpoint: '/createCohostRequest', + params: { targetUserId: cohostId }, + })); if (globalOpts['dryRun']) { - jsonOutput({ dryRun: true, endpoint: '/createEvent', payload, cohostsResolved: cohostIds.length, ...(opts['repeat'] ? { series: { repeat: opts['repeat'], count: opts['count'] } } : {}) }); + jsonOutput({ dryRun: true, endpoint: '/createEvent', payload, cohostsResolved: cohostIds.length, cohostInvites, ...(opts['repeat'] ? { series: { repeat: opts['repeat'], count: opts['count'] } } : {}) }); return; } @@ -244,11 +257,17 @@ export function registerEventsCommands(program: Command): void { d.setDate(d.getDate() + (i * days)); } const seriesEvent = { ...event, startDate: d.toISOString() }; - const seriesPayload = makePayload(config, { event: seriesEvent, cohostIds }); + const seriesPayload = makePayload(config, { event: seriesEvent, cohostIds: [] }); try { - const res = await apiRequest('POST', '/createEvent', token, seriesPayload, globalOpts['verbose'] as boolean | undefined) as { result?: { data?: unknown; eventId?: string } }; - const id = res.result?.data ?? res.result?.eventId; - results.push({ index: i + 1, status: 'created', title: opts['title'], date: d.toISOString(), id, url: `https://partiful.com/e/${String(id)}` }); + const res = await apiRequest('POST', '/createEvent', token, seriesPayload, globalOpts['verbose'] as boolean | undefined) as { result?: { data?: string | { id?: string }; eventId?: string } }; + const data = res.result?.data; + const id = typeof data === 'string' ? data : data?.id ?? res.result?.eventId; + if (!id) throw new Error('Partiful did not return an event ID'); + const inviteResults = await inviteCohostBatch( + id, cohostIds, [], makeCohostCall(token, config, globalOpts['verbose'] as boolean | undefined), + ); + if (inviteResults.failed.length > 0) process.exitCode = 1; + results.push({ index: i + 1, status: 'created', title: opts['title'], date: d.toISOString(), id, cohostInvites: inviteResults, url: `https://partiful.com/e/${id}` }); process.stderr.write(`[${i + 1}/${opts['count']}] Created: ${opts['title']} (${d.toLocaleDateString()})\n`); } catch (err) { results.push({ index: i + 1, status: 'error', title: opts['title'], date: d.toISOString(), error: (err as Error).message }); @@ -259,14 +278,21 @@ export function registerEventsCommands(program: Command): void { return; } - const result = await apiRequest('POST', '/createEvent', token, payload, globalOpts['verbose'] as boolean | undefined) as { result?: { data?: unknown; eventId?: string } }; - const newEventId = result.result?.data ?? result.result?.eventId; + const result = await apiRequest('POST', '/createEvent', token, payload, globalOpts['verbose'] as boolean | undefined) as { result?: { data?: string | { id?: string }; eventId?: string } }; + const data = result.result?.data; + const newEventId = typeof data === 'string' ? data : data?.id ?? result.result?.eventId; + if (!newEventId) throw new Error('Partiful did not return an event ID'); + const inviteResults = await inviteCohostBatch( + newEventId, cohostIds, [], makeCohostCall(token, config, globalOpts['verbose'] as boolean | undefined), + ); + if (inviteResults.failed.length > 0) process.exitCode = 1; jsonOutput({ id: newEventId, title: opts['title'], startDate: startDate.toISOString(), - url: `https://partiful.com/e/${String(newEventId)}`, + cohostInvites: inviteResults, + url: `https://partiful.com/e/${newEventId}`, }); } catch (e) { handleError(e); @@ -297,6 +323,9 @@ export function registerEventsCommands(program: Command): void { const fields: Record = {}; const updateFields: string[] = []; + let cohostIds: string[] = []; + let currentCohostIds: string[] = []; + let cohostStates: ReturnType = []; if (opts['title']) { fields['title'] = { stringValue: opts['title'] }; updateFields.push('title'); } if (opts['location']) { fields['location'] = { stringValue: opts['location'] }; updateFields.push('location'); } @@ -334,30 +363,58 @@ export function registerEventsCommands(program: Command): void { } if (opts['cohost'] && (opts['cohost'] as string[]).length > 0) { - const resolvedIds = await resolveCohostNames(opts['cohost'] as string[], token, config, globalOpts['verbose'] as boolean | undefined); - if (resolvedIds.length > 0) { - fields['cohostIds'] = { - arrayValue: { values: resolvedIds.map((id: string) => ({ stringValue: id })) } - }; - updateFields.push('cohostIds'); - } + const [resolvedIds, requests, existingIds] = await Promise.all([ + resolveCohostNames(opts['cohost'] as string[], token, config, globalOpts['verbose'] as boolean | undefined), + getCohostRequests(eventId, token, globalOpts['verbose'] as boolean | undefined), + getCohostIds(eventId, token, globalOpts['verbose'] as boolean | undefined), + ]); + cohostIds = resolvedIds; + currentCohostIds = existingIds; + cohostStates = mergeCohostState(requests, existingIds); } - if (updateFields.length === 0) { + if (updateFields.length === 0 && cohostIds.length === 0) { jsonError('No fields to update. Use --title, --location, --description, --date, --end-date, --capacity, --link, --poster, --poster-search, --image, or --cohost', 3, 'validation_error'); return; } + const cohostInvites = cohostIds.map((cohostId) => { + const state = cohostStates.find((item) => item.userId === cohostId); + return { + cohostId, + currentStatus: state?.status ?? null, + endpoints: state?.status === 'pending' || state?.status === 'accepted' + ? [] + : state?.status === 'stale' + ? ['Firestore PATCH cohostIds (remove stale ID)', '/createCohostRequest'] + : ['/createCohostRequest'], + }; + }); if (globalOpts['dryRun']) { - jsonOutput({ dryRun: true, eventId, fields: updateFields, body: { fields } }); + jsonOutput({ dryRun: true, eventId, fields: updateFields, body: { fields }, cohostInvites }); return; } - await firestoreRequest('PATCH', eventId, { fields }, token, updateFields, globalOpts['verbose'] as boolean | undefined); - + if (updateFields.length > 0) { + await firestoreRequest('PATCH', eventId, { fields }, token, updateFields, globalOpts['verbose'] as boolean | undefined); + } + const call = makeCohostCall(token, config, globalOpts['verbose'] as boolean | undefined); + const inviteResults = await inviteCohostBatch( + eventId, + cohostIds, + cohostStates, + call, + async (cohostId) => { + currentCohostIds = currentCohostIds.filter((id) => id !== cohostId); + await setCohostIds(eventId, currentCohostIds, token, globalOpts['verbose'] as boolean | undefined); + }, + ); + + if (inviteResults.failed.length > 0) process.exitCode = 1; jsonOutput({ id: eventId, updated: updateFields, + cohostInvites: inviteResults, url: `https://partiful.com/e/${eventId}`, }); } catch (e) { @@ -466,23 +523,33 @@ export function registerEventsCommands(program: Command): void { } const cohostIds = await resolveCohostNames((opts['cohost'] as string[] | undefined) ?? [], token, config, globalOpts['verbose'] as boolean | undefined); - - const payload = makePayload(config, { event, cohostIds }); + const payload = makePayload(config, { event, cohostIds: [] }); + const cohostInvites = cohostIds.map((cohostId) => ({ + endpoint: '/createCohostRequest', + params: { targetUserId: cohostId }, + })); if (globalOpts['dryRun']) { - jsonOutput({ dryRun: true, endpoint: '/createEvent', clonedFrom: eventId, payload }); + jsonOutput({ dryRun: true, endpoint: '/createEvent', clonedFrom: eventId, payload, cohostInvites }); return; } - const result = await apiRequest('POST', '/createEvent', token, payload, globalOpts['verbose'] as boolean | undefined) as { result?: { data?: unknown; eventId?: string } }; - const newEventId = result.result?.data ?? result.result?.eventId; + const result = await apiRequest('POST', '/createEvent', token, payload, globalOpts['verbose'] as boolean | undefined) as { result?: { data?: string | { id?: string }; eventId?: string } }; + const data = result.result?.data; + const newEventId = typeof data === 'string' ? data : data?.id ?? result.result?.eventId; + if (!newEventId) throw new Error('Partiful did not return an event ID'); + const inviteResults = await inviteCohostBatch( + newEventId, cohostIds, [], makeCohostCall(token, config, globalOpts['verbose'] as boolean | undefined), + ); + if (inviteResults.failed.length > 0) process.exitCode = 1; jsonOutput({ id: newEventId, clonedFrom: eventId, title: event['title'], startDate: newStart.toISOString(), - url: `https://partiful.com/e/${String(newEventId)}`, + cohostInvites: inviteResults, + url: `https://partiful.com/e/${newEventId}`, }); } catch (e) { handleError(e); diff --git a/src/commands/schema.ts b/src/commands/schema.ts index e190088..db257eb 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -145,8 +145,16 @@ const SCHEMAS: Record = { command: 'cohosts add ', parameters: { eventId: { type: 'string', required: true, positional: true, description: 'Event ID' }, - '--name': { type: 'string[]', required: false, description: 'Co-host names (resolved from contacts)' }, - '--user-id': { type: 'string[]', required: false, description: 'Co-host user IDs' }, + '--name': { type: 'string[]', required: false, description: 'Co-host names (must resolve uniquely from contacts)' }, + '--user-id': { type: 'string[]', required: false, description: 'Partiful user IDs' }, + }, + }, + 'cohosts.link': { + command: 'cohosts link ', + parameters: { + eventId: { type: 'string', required: true, positional: true, description: 'Event ID' }, + '--enable': { type: 'boolean', required: false, description: 'Enable/create the co-host invite link' }, + '--disable': { type: 'boolean', required: false, description: 'Disable/revoke the co-host invite link' }, }, }, 'cohosts.remove': { diff --git a/src/lib/api/endpoints.ts b/src/lib/api/endpoints.ts index 36e9c6e..3478d30 100644 --- a/src/lib/api/endpoints.ts +++ b/src/lib/api/endpoints.ts @@ -191,6 +191,39 @@ export const AddInvitedGuestsAsHostResponseSchema = z.object({}).passthrough(); export type AddInvitedGuestsAsHostData = z.infer; export type AddInvitedGuestsAsHostResponse = CallableResult; +// --- canonical cohost lifecycle ------------------------------------------- +export interface CohostRequestParams { + eventId: string; + targetUserId: string; +} +export type CreateCohostRequestRequest = CallableEnvelope; +export const CreateCohostRequestResponseSchema = z.object({}).passthrough(); +export type CreateCohostRequestData = z.infer; +export type CreateCohostRequestResponse = CallableResult; + +export type DeleteCohostRequestRequest = CallableEnvelope; +export const DeleteCohostRequestResponseSchema = z.object({}).passthrough(); +export type DeleteCohostRequestData = z.infer; +export type DeleteCohostRequestResponse = CallableResult; + +export type RemoveCohostRequest = CallableEnvelope; +export const RemoveCohostResponseSchema = z.object({}).passthrough(); +export type RemoveCohostData = z.infer; +export type RemoveCohostResponse = CallableResult; + +export interface EventCohostLinkParams { + eventId: string; +} +export type GenerateEventCohostLinkRequest = CallableEnvelope; +export const GenerateEventCohostLinkResponseSchema = z.object({ path: z.string().optional() }).passthrough(); +export type GenerateEventCohostLinkData = z.infer; +export type GenerateEventCohostLinkResponse = CallableResult; + +export type RevokeEventCohostLinkRequest = CallableEnvelope; +export const RevokeEventCohostLinkResponseSchema = z.object({}).passthrough(); +export type RevokeEventCohostLinkData = z.infer; +export type RevokeEventCohostLinkResponse = CallableResult; + // --- getMyUpcomingEventsForHomePage ---------------------------------------- export interface GetMyUpcomingEventsParams { // empty params @@ -377,6 +410,26 @@ export const apiEndpoints = { requestParams: ['eventId', 'guests'], responseFields: fieldsOf(AddInvitedGuestsAsHostResponseSchema), }, + createCohostRequest: { + method: 'POST', host: HOST_CALLABLE, path: '/createCohostRequest', transport: 'firebase-callable', + requestParams: ['eventId', 'targetUserId'], responseFields: fieldsOf(CreateCohostRequestResponseSchema), + }, + deleteCohostRequest: { + method: 'POST', host: HOST_CALLABLE, path: '/deleteCohostRequest', transport: 'firebase-callable', + requestParams: ['eventId', 'targetUserId'], responseFields: fieldsOf(DeleteCohostRequestResponseSchema), + }, + removeCohost: { + method: 'POST', host: HOST_CALLABLE, path: '/removeCohost', transport: 'firebase-callable', + requestParams: ['eventId', 'targetUserId'], responseFields: fieldsOf(RemoveCohostResponseSchema), + }, + generateEventCohostLink: { + method: 'POST', host: HOST_CALLABLE, path: '/generateEventCohostLink', transport: 'firebase-callable', + requestParams: ['eventId'], responseFields: fieldsOf(GenerateEventCohostLinkResponseSchema), + }, + revokeEventCohostLink: { + method: 'POST', host: HOST_CALLABLE, path: '/revokeEventCohostLink', transport: 'firebase-callable', + requestParams: ['eventId'], responseFields: fieldsOf(RevokeEventCohostLinkResponseSchema), + }, getMyUpcomingEventsForHomePage: { method: 'POST', host: HOST_CALLABLE, @@ -468,6 +521,11 @@ export const responseSchemas = { getContacts: ContactSchema, createTextBlast: CreateTextBlastResponseSchema, addInvitedGuestsAsHost: AddInvitedGuestsAsHostResponseSchema, + createCohostRequest: CreateCohostRequestResponseSchema, + deleteCohostRequest: DeleteCohostRequestResponseSchema, + removeCohost: RemoveCohostResponseSchema, + generateEventCohostLink: GenerateEventCohostLinkResponseSchema, + revokeEventCohostLink: RevokeEventCohostLinkResponseSchema, getMyUpcomingEventsForHomePage: HomePageEventSchema, getMyPastEventsForHomePage: HomePageEventSchema, addGuest: AddGuestResponseSchema, diff --git a/src/lib/cohosts.ts b/src/lib/cohosts.ts index 1dded79..e1b9e08 100644 --- a/src/lib/cohosts.ts +++ b/src/lib/cohosts.ts @@ -1,30 +1,82 @@ -/** - * Shared co-host helpers: contact resolution, Firestore read/write. - */ +/** Canonical cohost lifecycle helpers. */ -import { apiRequest, firestoreRequest } from './http.js'; +import { apiRequest, firestoreRequest, firestoreListDocuments, firestoreGetDocument } from './http.js'; import { wrapPayload } from './auth.js'; import type { PartifulConfig } from './auth.js'; import type { GetContactsData } from './api/endpoints.js'; +import { NotFoundError, ValidationError } from './errors.js'; -/** A firebase-callable result wrapping the getContacts array. */ interface GetContactsEnvelope { result?: { data?: GetContactsData }; } -/** - * Resolve co-host names to Partiful user IDs via the contacts API. - * Tries exact match first, then substring. Warns on stderr for misses. - * @returns resolved user IDs - */ -export async function resolveCohostNames( - names: string[], +export interface CohostContact { + userId?: string; + name?: string; +} + +export type CohostStatus = 'pending' | 'accepted' | 'declined' | 'stale'; +export interface CohostState { + userId: string; + status: CohostStatus; + name?: string | null; +} + +interface RawCohostRequest { + userId: string; + status: string; +} + +interface FirestoreValue { + stringValue?: string; + timestampValue?: string; +} + +interface FirestoreDocument { + name?: string; + fields?: Record; +} + +interface FirestoreListEnvelope { + documents?: FirestoreDocument[]; +} + +export type CanonicalCohostCall = (endpoint: string, params: Record) => Promise; + +/** Resolve every supplied name uniquely. Any miss or ambiguity fails the command. */ +export function resolveContactNames(names: string[], contacts: CohostContact[]): string[] { + const ids: string[] = []; + for (const name of names ?? []) { + const query = name.trim().toLowerCase(); + if (!query) throw new ValidationError('Co-host name cannot be empty'); + + const usable = contacts.filter((contact) => contact.userId && contact.name); + const exact = usable.filter((contact) => contact.name!.trim().toLowerCase() === query); + const candidates = exact.length > 0 + ? exact + : usable.filter((contact) => contact.name!.toLowerCase().includes(query)); + + if (candidates.length === 0) { + throw new ValidationError(`Could not resolve co-host "${name}" from contacts`, { query: name }); + } + if (candidates.length > 1) { + throw new ValidationError(`Ambiguous co-host name "${name}"`, { + query: name, + candidates: candidates.map(({ userId, name: candidateName }) => ({ userId, name: candidateName })), + }); + } + + const id = candidates[0]!.userId!; + if (!ids.includes(id)) ids.push(id); + } + return ids; +} + +export async function getContacts( token: string, config: PartifulConfig, verbose = false, -): Promise { - if (!names || names.length === 0) return []; - +): Promise { const payload = { data: wrapPayload(config, { params: {}, @@ -33,50 +85,32 @@ export async function resolveCohostNames( }), }; const result = (await apiRequest('POST', '/getContacts', token, payload, verbose)) as GetContactsEnvelope; - const contacts = result.result?.data || []; + return result.result?.data || []; +} - const ids: string[] = []; - for (const name of names) { - const q = name.toLowerCase(); - const match = - contacts.find((c) => (c.name || '').toLowerCase() === q) || - contacts.find((c) => (c.name || '').toLowerCase().includes(q)); - if (match?.userId) { - if (!ids.includes(match.userId)) ids.push(match.userId); - } else { - process.stderr.write(`Warning: could not resolve co-host "${name}" from contacts — skipping\n`); - } - } - return ids; +export async function resolveCohostNames( + names: string[], + token: string, + config: PartifulConfig, + verbose = false, +): Promise { + if (!names || names.length === 0) return []; + return resolveContactNames(names, await getContacts(token, config, verbose)); } -/** A Firestore event doc, narrowed to the cohostIds array field we read. */ interface FirestoreEventDoc { fields?: { - cohostIds?: { - arrayValue?: { - values?: Array<{ stringValue?: string }>; - }; - }; + cohostIds?: { arrayValue?: { values?: Array<{ stringValue?: string }> } }; }; } -/** - * Read cohostIds array from a Firestore event document. - */ -export async function getCohostIds( - eventId: string, - token: string, - verbose = false, -): Promise { +export async function getCohostIds(eventId: string, token: string, verbose = false): Promise { const doc = (await firestoreRequest('GET', eventId, null, token, [], verbose)) as FirestoreEventDoc; const values = doc.fields?.cohostIds?.arrayValue?.values || []; - return values.map((v) => v.stringValue).filter((v): v is string => Boolean(v)); + return values.map((value) => value.stringValue).filter((value): value is string => Boolean(value)); } -/** - * Write cohostIds array to a Firestore event document. - */ +/** @deprecated Lifecycle commands must use canonical callables, not raw membership writes. */ export async function setCohostIds( eventId: string, ids: string[], @@ -84,10 +118,152 @@ export async function setCohostIds( verbose = false, ): Promise { const unique = [...new Set(ids.filter(Boolean))]; - const fields = { - cohostIds: { - arrayValue: { values: unique.map((id) => ({ stringValue: id })) }, - }, - }; + const fields = { cohostIds: { arrayValue: { values: unique.map((id) => ({ stringValue: id })) } } }; await firestoreRequest('PATCH', eventId, { fields }, token, ['cohostIds'], verbose); } + +export async function getCohostRequests(eventId: string, token: string, verbose = false): Promise { + const result = (await firestoreListDocuments( + `events/${eventId}/cohostRequests`, token, 100, null, verbose, + )) as FirestoreListEnvelope; + return (result.documents ?? []).map((document) => ({ + userId: document.fields?.targetUserId?.stringValue || document.fields?.cohostId?.stringValue || document.name?.split('/').pop() || '', + status: document.fields?.status?.stringValue || 'PENDING', + })).filter((request) => Boolean(request.userId)); +} + +export function mergeCohostState(requests: RawCohostRequest[], cohostIds: string[]): CohostState[] { + const states: CohostState[] = requests.map((request) => { + const normalized = request.status.toLowerCase(); + const status: CohostStatus = normalized === 'accepted' || normalized === 'declined' ? normalized : 'pending'; + return { userId: request.userId, status }; + }); + const requestIds = new Set(states.map((state) => state.userId)); + for (const userId of cohostIds) { + if (!requestIds.has(userId)) states.push({ userId, status: 'stale' }); + } + return states; +} + +export async function getCohostState(eventId: string, token: string, verbose = false): Promise { + const [requests, ids] = await Promise.all([ + getCohostRequests(eventId, token, verbose), + getCohostIds(eventId, token, verbose), + ]); + return mergeCohostState(requests, ids); +} + +export type CohostInviteAction = 'invite' | 'repair' | 'reinvite' | 'noop'; +export function planCohostInvite(state?: CohostState): CohostInviteAction { + if (!state) return 'invite'; + if (state.status === 'stale') return 'repair'; + if (state.status === 'declined') return 'reinvite'; + return 'noop'; +} + +export type CohostRemovalAction = 'delete_request' | 'remove_cohost'; +export function planCohostRemoval(state?: CohostState): CohostRemovalAction { + if (!state) throw new NotFoundError('User is not a co-host of this event'); + return state.status === 'pending' || state.status === 'declined' ? 'delete_request' : 'remove_cohost'; +} + +export async function inviteCohost( + eventId: string, + userId: string, + state: CohostState | undefined, + call: CanonicalCohostCall, + repairStale?: () => Promise, +): Promise<{ userId: string; outcome: string }> { + const action = planCohostInvite(state); + if (action === 'noop') return { userId, outcome: state!.status }; + if (action === 'repair') { + if (!repairStale) throw new ValidationError('Stale co-host membership requires an explicit repair hook'); + // Both canonical endpoints return INTERNAL for a legacy ID with no request. + await repairStale(); + } + await call('/createCohostRequest', { eventId, targetUserId: userId }); + const outcome = action === 'repair' ? 'stale_repair' : action === 'reinvite' ? 'reinvited' : 'invited'; + return { userId, outcome }; +} + +export async function inviteCohostBatch( + eventId: string, + userIds: string[], + states: CohostState[], + call: CanonicalCohostCall, + repairStale?: (userId: string) => Promise, +): Promise<{ + succeeded: Array<{ userId: string; outcome: string }>; + failed: Array<{ userId: string; error: string }>; +}> { + const succeeded: Array<{ userId: string; outcome: string }> = []; + const failed: Array<{ userId: string; error: string }> = []; + for (const userId of userIds) { + const state = states.find((item) => item.userId === userId); + try { + succeeded.push(await inviteCohost( + eventId, + userId, + state, + call, + state?.status === 'stale' && repairStale ? () => repairStale(userId) : undefined, + )); + } catch (error) { + failed.push({ userId, error: error instanceof Error ? error.message : String(error) }); + } + } + return { succeeded, failed }; +} + +export async function removeCohostCanonical( + eventId: string, + state: CohostState | undefined, + call: CanonicalCohostCall, + removeStale?: () => Promise, +): Promise<{ userId: string; outcome: 'removed' }> { + const action = planCohostRemoval(state); + if (state?.status === 'stale') { + if (!removeStale) throw new ValidationError('Stale co-host membership requires an explicit removal hook'); + await removeStale(); + } else { + const endpoint = action === 'delete_request' ? '/deleteCohostRequest' : '/removeCohost'; + await call(endpoint, { eventId, targetUserId: state!.userId }); + } + return { userId: state!.userId, outcome: 'removed' }; +} + +export function cohostPathToUrl(path: string): string { + if (!/^\/e\/[^/?#]+\?[^#]*\baccept-cohost=[^&#]+/.test(path)) { + throw new ValidationError('Invalid cohost invite-link path returned by Partiful', { path }); + } + return `https://partiful.com${path}`; +} + +interface CohostSecretDocument { + fields?: { path?: { stringValue?: string } }; +} + +export interface CohostLinkState { + enabled: boolean; + url: string | null; +} + +export function parseCohostLinkDocument(document: CohostSecretDocument | null): CohostLinkState { + if (!document) return { enabled: false, url: null }; + const path = document.fields?.path?.stringValue; + if (!path) throw new ValidationError('Invalid cohost invite-link document: path missing'); + return { enabled: true, url: cohostPathToUrl(path) }; +} + +export async function getCohostLink(eventId: string, token: string, verbose = false): Promise { + const document = await firestoreGetDocument(`events/${eventId}/private/cohostSecret`, token, verbose); + return parseCohostLinkDocument(document as CohostSecretDocument | null); +} + +export type CohostLinkRequest = 'inspect' | 'enable' | 'disable'; +export type CohostLinkAction = 'inspect' | 'generate' | 'revoke' | 'noop'; +export function planCohostLinkAction(requested: CohostLinkRequest, enabled: boolean): CohostLinkAction { + if (requested === 'inspect') return 'inspect'; + if (requested === 'enable') return enabled ? 'noop' : 'generate'; + return enabled ? 'revoke' : 'noop'; +} diff --git a/src/lib/http.ts b/src/lib/http.ts index e2c7296..323b409 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -164,6 +164,32 @@ export async function firestoreRequest( return text ? JSON.parse(text) : {}; } +export async function firestoreGetDocument( + documentPath: string, + token: string, + verbose = false, +): Promise { + const normalized = documentPath.split('/').filter(Boolean).map(encodeURIComponent).join('/'); + const fsPath = `/v1/projects/${FIRESTORE_PROJECT}/databases/(default)/documents/${normalized}`; + const resp = await withRetry( + () => fetch(`${FIRESTORE_BASE}${fsPath}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + Referer: 'https://partiful.com/', + }, + }), + verbose, + ); + if (resp.status === 404) return null; + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + throw classifyError(resp.status, 'Firestore document GET failed', text); + } + const text = await resp.text(); + return text ? JSON.parse(text) : {}; +} + export async function firestoreListDocuments( collectionPath: string, token: string, diff --git a/tests/cohosts.test.js b/tests/cohosts.test.js new file mode 100644 index 0000000..ca4f266 --- /dev/null +++ b/tests/cohosts.test.js @@ -0,0 +1,211 @@ +import { describe, it, expect, vi } from 'vitest'; +import { run, runRaw } from './helpers.js'; +import { + resolveContactNames, + mergeCohostState, + planCohostInvite, + planCohostRemoval, + cohostPathToUrl, + inviteCohost, + inviteCohostBatch, + removeCohostCanonical, + parseCohostLinkDocument, + planCohostLinkAction, +} from '../src/lib/cohosts.js'; + +const contacts = [ + { userId: 'u1', name: 'Alex Smith' }, + { userId: 'u2', name: 'Alex Johnson' }, + { userId: 'u3', name: 'Sam Lee' }, +]; + +describe('resolveContactNames', () => { + it('prefers a unique exact name match', () => { + expect(resolveContactNames(['Alex Smith'], contacts)).toEqual(['u1']); + }); + + it('allows a unique partial match', () => { + expect(resolveContactNames(['Sam'], contacts)).toEqual(['u3']); + }); + + it('fails closed for ambiguous partial names and includes candidates', () => { + expect(() => resolveContactNames(['Alex'], contacts)).toThrowError(/ambiguous/i); + try { + resolveContactNames(['Alex'], contacts); + } catch (error) { + expect(error.details.candidates).toEqual([ + { userId: 'u1', name: 'Alex Smith' }, + { userId: 'u2', name: 'Alex Johnson' }, + ]); + } + }); + + it('fails when an exact name is duplicated', () => { + const duplicate = [...contacts, { userId: 'u4', name: 'Alex Smith' }]; + expect(() => resolveContactNames(['Alex Smith'], duplicate)).toThrowError(/ambiguous/i); + }); + + it('fails on unresolved names instead of warning and continuing', () => { + expect(() => resolveContactNames(['Nobody Here'], contacts)).toThrowError(/could not resolve/i); + }); + + it('deduplicates resolved user ids', () => { + expect(resolveContactNames(['Sam Lee', 'sam'], contacts)).toEqual(['u3']); + }); +}); + +describe('cohost lifecycle state', () => { + const requests = [ + { userId: 'u1', status: 'PENDING' }, + { userId: 'u2', status: 'ACCEPTED' }, + { userId: 'u3', status: 'DECLINED' }, + ]; + + it('normalizes requests and marks ids without a request as stale', () => { + expect(mergeCohostState(requests, ['u2', 'legacy'])).toEqual([ + { userId: 'u1', status: 'pending' }, + { userId: 'u2', status: 'accepted' }, + { userId: 'u3', status: 'declined' }, + { userId: 'legacy', status: 'stale' }, + ]); + }); + + it.each([ + [undefined, 'invite'], + ['stale', 'repair'], + ['declined', 'reinvite'], + ['pending', 'noop'], + ['accepted', 'noop'], + ])('plans invite for %s as %s', (status, action) => { + const state = status ? { userId: 'u1', status } : undefined; + expect(planCohostInvite(state)).toBe(action); + }); + + it.each([ + ['pending', 'delete_request'], + ['declined', 'delete_request'], + ['accepted', 'remove_cohost'], + ['stale', 'remove_cohost'], + ])('plans removal for %s as %s', (status, action) => { + expect(planCohostRemoval({ userId: 'u1', status })).toBe(action); + }); + + it('fails removal when the user has no lifecycle state', () => { + expect(() => planCohostRemoval(undefined)).toThrowError(/not a co-host/i); + }); +}); + +describe('canonical orchestration', () => { + it.each([ + [undefined, 'invited'], + ['declined', 'reinvited'], + ])('calls createCohostRequest for %s state', async (status, outcome) => { + const call = vi.fn().mockResolvedValue({}); + const state = status ? { userId: 'u1', status } : undefined; + expect(await inviteCohost('event', 'u1', state, call)).toEqual({ userId: 'u1', outcome }); + expect(call).toHaveBeenCalledWith('/createCohostRequest', { eventId: 'event', targetUserId: 'u1' }); + }); + + it('repairs stale direct membership before creating the request', async () => { + const call = vi.fn().mockResolvedValue({}); + const repairStale = vi.fn().mockResolvedValue(undefined); + expect(await inviteCohost('event', 'u1', { userId: 'u1', status: 'stale' }, call, repairStale)) + .toEqual({ userId: 'u1', outcome: 'stale_repair' }); + expect(repairStale).toHaveBeenCalledOnce(); + expect(call.mock.calls).toEqual([ + ['/createCohostRequest', { eventId: 'event', targetUserId: 'u1' }], + ]); + }); + + it('fails closed rather than issuing a stale request without a repair hook', async () => { + const call = vi.fn(); + await expect(inviteCohost('event', 'u1', { userId: 'u1', status: 'stale' }, call)) + .rejects.toThrow('repair hook'); + expect(call).not.toHaveBeenCalled(); + }); + + it('reports partial batch failures without hiding successful invitations', async () => { + const call = vi.fn(async (_endpoint, params) => { + if (params.targetUserId === 'u2') throw new Error('denied'); + return {}; + }); + await expect(inviteCohostBatch('event', ['u1', 'u2'], [], call)).resolves.toEqual({ + succeeded: [{ userId: 'u1', outcome: 'invited' }], + failed: [{ userId: 'u2', error: 'denied' }], + }); + }); + + it.each(['pending', 'accepted'])('does not mutate an existing %s request', async (status) => { + const call = vi.fn(); + expect(await inviteCohost('event', 'u1', { userId: 'u1', status }, call)).toEqual({ userId: 'u1', outcome: status }); + expect(call).not.toHaveBeenCalled(); + }); + + it('routes pending request removal to deleteCohostRequest', async () => { + const call = vi.fn().mockResolvedValue({}); + await removeCohostCanonical('event', { userId: 'u1', status: 'pending' }, call); + expect(call).toHaveBeenCalledWith('/deleteCohostRequest', { eventId: 'event', targetUserId: 'u1' }); + }); + + it('routes accepted removal to removeCohost', async () => { + const call = vi.fn().mockResolvedValue({}); + await removeCohostCanonical('event', { userId: 'u1', status: 'accepted' }, call); + expect(call).toHaveBeenCalledWith('/removeCohost', { eventId: 'event', targetUserId: 'u1' }); + }); + + it('removes stale membership through the explicit migration hook', async () => { + const call = vi.fn(); + const removeStale = vi.fn().mockResolvedValue(undefined); + await removeCohostCanonical('event', { userId: 'u1', status: 'stale' }, call, removeStale); + expect(removeStale).toHaveBeenCalledOnce(); + expect(call).not.toHaveBeenCalled(); + }); +}); + +describe('cohost command surfaces', () => { + it('registers link lifecycle help', () => { + const { stdout, exitCode } = runRaw(['cohosts', 'link', '--help']); + expect(exitCode).toBe(0); + expect(stdout).toContain('--enable'); + expect(stdout).toContain('--disable'); + }); + + it('exposes cohosts.link schema', () => { + const out = run(['schema', 'cohosts.link']); + expect(out.data.command).toBe('cohosts link '); + expect(out.data.parameters['--enable']).toBeDefined(); + expect(out.data.parameters['--disable']).toBeDefined(); + }); +}); + +describe('cohost invite links', () => { + it('converts server path to an absolute Partiful URL', () => { + expect(cohostPathToUrl('/e/event?accept-cohost=token')).toBe('https://partiful.com/e/event?accept-cohost=token'); + }); + + it('rejects non-Partiful and malformed paths', () => { + expect(() => cohostPathToUrl('https://evil.test/x')).toThrowError(/invalid/i); + expect(() => cohostPathToUrl('/other/path')).toThrowError(/invalid/i); + }); + + it('parses missing and active secret documents', () => { + expect(parseCohostLinkDocument(null)).toEqual({ enabled: false, url: null }); + expect(parseCohostLinkDocument({ fields: { path: { stringValue: '/e/event?accept-cohost=token' } } })) + .toEqual({ enabled: true, url: 'https://partiful.com/e/event?accept-cohost=token' }); + }); + + it('fails closed when an existing secret document has no valid path', () => { + expect(() => parseCohostLinkDocument({ fields: {} })).toThrowError(/invalid/i); + }); + + it.each([ + ['inspect', false, 'inspect'], + ['inspect', true, 'inspect'], + ['enable', false, 'generate'], + ['enable', true, 'noop'], + ['disable', false, 'noop'], + ['disable', true, 'revoke'], + ])('plans %s when enabled=%s as %s', (requested, enabled, expected) => { + expect(planCohostLinkAction(requested, enabled)).toBe(expected); + }); +}); diff --git a/tests/http.test.js b/tests/http.test.js index 07f41c7..4383a29 100644 --- a/tests/http.test.js +++ b/tests/http.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { apiRequest, firestoreRequest, firestoreListDocuments } from '../src/lib/http.js'; +import { apiRequest, firestoreRequest, firestoreListDocuments, firestoreGetDocument } from '../src/lib/http.js'; describe('http module exports', () => { it('exports apiRequest as function', () => { @@ -11,4 +11,7 @@ describe('http module exports', () => { it('exports firestoreListDocuments as function', () => { expect(typeof firestoreListDocuments).toBe('function'); }); + it('exports firestoreGetDocument as function', () => { + expect(typeof firestoreGetDocument).toBe('function'); + }); }); diff --git a/tests/schema-api.test.js b/tests/schema-api.test.js index f775ce3..feac8c4 100644 --- a/tests/schema-api.test.js +++ b/tests/schema-api.test.js @@ -33,6 +33,19 @@ describe('schema api. namespace', () => { expect(Array.isArray(out.data.responseFields)).toBe(true); }); + it.each([ + ['createCohostRequest', '/createCohostRequest', ['eventId', 'targetUserId']], + ['deleteCohostRequest', '/deleteCohostRequest', ['eventId', 'targetUserId']], + ['removeCohost', '/removeCohost', ['eventId', 'targetUserId']], + ['generateEventCohostLink', '/generateEventCohostLink', ['eventId']], + ['revokeEventCohostLink', '/revokeEventCohostLink', ['eventId']], + ])('exposes canonical cohost endpoint %s', (method, path, params) => { + const out = run(['schema', `api.${method}`]); + expect(out.data.path).toBe(path); + expect(out.data.transport).toBe('firebase-callable'); + expect(out.data.requestParams).toEqual(params); + }); + it('`schema api.firestoreGetEvent` reflects firestore transport + GET', () => { const out = run(['schema', 'api.firestoreGetEvent']); expect(out.data.transport).toBe('firestore');