Skip to content

fix: complete cohost lifecycle and invite-link support - #75

Merged
KalebCole merged 1 commit into
mainfrom
fix/cohost-lifecycle-links
Aug 1, 2026
Merged

fix: complete cohost lifecycle and invite-link support#75
KalebCole merged 1 commit into
mainfrom
fix/cohost-lifecycle-links

Conversation

@KalebCole

@KalebCole KalebCole commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Summary

  • replace raw cohostIds writes with Partiful's canonical cohost request lifecycle
  • add cohosts link inspect/enable/disable workflow and schema/API discovery
  • repair legacy cohostIds-only corruption before issuing a canonical request
  • route event create/update/clone cohost flags through canonical invitations
  • add fail-closed name resolution, merged pending/accepted/declined/stale state, partial-failure reporting, tests, and skill docs

Production API findings

  • cohost request callables require { eventId, targetUserId }
  • a legacy raw cohost ID with no request causes canonical add/remove to return INTERNAL; repair must first remove that stale ID
  • cohost invite links are persisted at events/{eventId}/private/cohostSecret

Verification

  • npm test (273 passed, 6 skipped)
  • npm run typecheck
  • git diff --check
  • live stale-state repair followed by canonical direct-add
  • live request persisted as pending; repeat add was an idempotent no-op
  • live invite-link generate, inspect, and revoke
  • independent cross-model review: APPROVE_WITH_NITS; fixed duplicate-read race and re-ran full suite
  • recipient-side acceptance / Hosted by / host controls, blocked because no second-account session is available in this environment

Notes

The initial live INTERNAL responses were caused by two production contract details discovered during verification: the callable parameter is targetUserId, not cohostId, and legacy stale membership must be cleared before canonical repair. Both are covered by the implementation and tests.

Closes #74

Summary by CodeRabbit

  • New Features

    • Added canonical co-host invitation and removal workflows.
    • Added co-host invite-link inspection, generation, revocation, and dry-run support.
    • Added name resolution, duplicate filtering, stale-state repair, and per-person outcome reporting.
    • Event creation, recurring events, cloning, and updates now process co-host invitations separately.
  • Bug Fixes

    • Improved handling of missing, ambiguous, stale, and failed co-host operations.
  • Documentation

    • Updated co-host, guest invitation, event, privacy, and task-routing guidance.

Replace raw cohost membership writes with Partiful request callables,
add invite-link lifecycle commands, repair legacy stale membership,
and route event create/update/clone through canonical invitations.

Add schema coverage, unit/orchestration tests, skill documentation,
and live-tested error handling for partial failures.

Closes #74
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds canonical cohost request and removal lifecycles, strict contact resolution, invite-link commands, event integration, typed APIs, Firestore reads, tests, and updated documentation.

Changes

Cohost lifecycle

Layer / File(s) Summary
Cohost contracts and lifecycle foundation
src/lib/api/endpoints.ts, src/lib/cohosts.ts, src/lib/http.ts, tests/*
Adds typed cohost endpoints, strict name resolution, normalized request and membership states, canonical invite/removal operations, invite-link handling, Firestore document reads, and automated coverage.
Cohost commands and link controls
src/commands/cohosts.ts, src/commands/schema.ts, skills/partiful/references/guests-invitations-and-cohosts.md, tests/cohosts.test.js
Updates listing, add, and remove flows to use canonical lifecycle actions. Adds cohosts link with inspection, enable, disable, dry-run, and response validation behavior.
Event cohost integration
src/commands/events.ts, skills/partiful/SKILL.md, skills/partiful/references/events.md
Removes direct cohost IDs from event creation, update, recurring creation, and cloning payloads. Sends canonical invitations after event creation and reconciles them during updates.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant EventCommand
  participant CreateEventAPI
  participant CohostLifecycleAPI
  participant EventOutput
  EventCommand->>CreateEventAPI: Create event with an empty cohost list
  CreateEventAPI-->>EventCommand: Return event ID
  EventCommand->>CohostLifecycleAPI: Send canonical cohost requests
  CohostLifecycleAPI-->>EventCommand: Return invitation outcomes
  EventCommand->>EventOutput: Report results and failures
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the cohost lifecycle and invite-link changes.
Linked Issues check ✅ Passed The PR satisfies the coding objectives in [#74], including canonical lifecycle handling, link workflows, stale-state repair, documentation, schemas, and tests.
Out of Scope Changes check ✅ Passed The changes remain within [#74], covering cohost lifecycle implementation, invite links, event integration, tests, schemas, and related documentation.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/cohost-lifecycle-links

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
src/commands/events.ts (1)

233-242: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract a shared helper for post-creation cohost invitation.

The pattern of extracting the new event ID (typeof data === 'string' ? data : data?.id ?? result.result?.eventId, with the if (!id) throw ... guard) followed by inviteCohostBatch(id, cohostIds, [], makeCohostCall(...)) is repeated at three sites: single create (Lines 281-295), series create (Lines 260-270), and clone (Lines 526-552). The dry-run preview array { endpoint: '/createCohostRequest', params: { targetUserId: cohostId } } is also duplicated verbatim between create (Lines 236-239) and clone (Lines 527-530).

Because these are separate, independently maintained code blocks, a future change to the endpoint, invite params, or ID-extraction fallback order can silently drift between sites (for example, the dry-run preview could get out of sync with the actual runtime call). Extract a shared function to keep the behavior and its preview in one place.

♻️ Proposed shared helper
async function createEventAndInviteCohosts(
  token: string,
  config: ReturnType<typeof loadConfig>,
  payload: Record<string, unknown>,
  cohostIds: string[],
  verbose?: boolean,
): Promise<{ id: string; inviteResults: Awaited<ReturnType<typeof inviteCohostBatch>> }> {
  const result = await apiRequest('POST', '/createEvent', token, payload, verbose) as {
    result?: { data?: string | { id?: string }; eventId?: string };
  };
  const data = result.result?.data;
  const id = typeof data === 'string' ? data : data?.id ?? result.result?.eventId;
  if (!id) throw new Error('Partiful did not return an event ID');
  const inviteResults = await inviteCohostBatch(id, cohostIds, [], makeCohostCall(token, config, verbose));
  return { id, inviteResults };
}

function planCohostInvites(cohostIds: string[]): Array<{ endpoint: string; params: { targetUserId: string } }> {
  return cohostIds.map((cohostId) => ({ endpoint: '/createCohostRequest', params: { targetUserId: cohostId } }));
}

Also applies to: 260-270, 281-295, 526-552

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/events.ts` around lines 233 - 242, Extract shared
createEventAndInviteCohosts and planCohostInvites helpers in events.ts,
preserving the existing event-ID fallback order and missing-ID error. Replace
the repeated single-create, series-create, and clone runtime invitation blocks
with the shared helper, and use planCohostInvites for both create and clone
dry-run previews so endpoint and parameter construction remain consistent.
src/commands/cohosts.ts (1)

154-157: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid a redundant Firestore read for cohostIds.

getCohostState (154) already calls getCohostRequests and getCohostIds internally and merges them. Calling getCohostIds again on the next line (156) issues a third Firestore GET for data already fetched inside getCohostState, purely to get raw IDs for the stale-repair closure.

The add command avoids this by calling getCohostRequests and getCohostIds directly and merging with mergeCohostState locally instead of going through getCohostState. Mirroring that pattern here removes one network round trip on every cohosts remove invocation.

♻️ Proposed refactor to avoid the duplicate read
-        const [states, currentIds] = await Promise.all([
-          getCohostState(eventId, token, verbose),
-          getCohostIds(eventId, token, verbose),
-        ]);
+        const [requests, currentIds] = await Promise.all([
+          getCohostRequests(eventId, token, verbose),
+          getCohostIds(eventId, token, verbose),
+        ]);
+        const states = mergeCohostState(requests, currentIds);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/cohosts.ts` around lines 154 - 157, Update the cohosts remove
flow around getCohostState to avoid fetching cohost IDs twice: call
getCohostRequests and getCohostIds directly, merge their results with
mergeCohostState, and retain the raw IDs for the stale-repair closure. Preserve
the existing remove behavior while eliminating the redundant
getCohostState-triggered read.
src/lib/api/endpoints.ts (1)

194-226: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider z.looseObject() instead of .passthrough().

These five new schemas use z.object({}).passthrough(). Zod 4 still supports .passthrough(), but treats it as legacy: "These methods are still available for backwards compatibility, and they will not be removed." The library also recommends the newer top-level constructor for new schemas going forward.

Since all five schemas are new code in this PR, switching to z.looseObject({}) (and z.looseObject({ path: z.string().optional() }) for the link-generation schema) aligns with the current recommended API without changing behavior.

♻️ Proposed refactor to the new schemas
-export const CreateCohostRequestResponseSchema = z.object({}).passthrough();
+export const CreateCohostRequestResponseSchema = z.looseObject({});
...
-export const DeleteCohostRequestResponseSchema = z.object({}).passthrough();
+export const DeleteCohostRequestResponseSchema = z.looseObject({});
...
-export const RemoveCohostResponseSchema = z.object({}).passthrough();
+export const RemoveCohostResponseSchema = z.looseObject({});
...
-export const GenerateEventCohostLinkResponseSchema = z.object({ path: z.string().optional() }).passthrough();
+export const GenerateEventCohostLinkResponseSchema = z.looseObject({ path: z.string().optional() });
...
-export const RevokeEventCohostLinkResponseSchema = z.object({}).passthrough();
+export const RevokeEventCohostLinkResponseSchema = z.looseObject({});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/api/endpoints.ts` around lines 194 - 226, Replace the legacy
.passthrough() calls in CreateCohostRequestResponseSchema,
DeleteCohostRequestResponseSchema, RemoveCohostResponseSchema,
GenerateEventCohostLinkResponseSchema, and RevokeEventCohostLinkResponseSchema
with z.looseObject(), preserving the existing fields including optional path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/commands/cohosts.ts`:
- Around line 116-136: Replace the manual invitation loop in the cohosts command
with the shared inviteCohostBatch function from ../lib/cohosts.js, removing the
direct inviteCohost import. Pass the existing event, IDs, state, call, and
currentIds stale-repair behavior through inviteCohostBatch, preserving its
standardized error formatting and the existing ApiError/jsonOutput handling.

In `@src/lib/cohosts.ts`:
- Around line 113-123: Prevent setCohostIds from blindly replacing a
concurrently changed cohostIds value during stale repair. Before the PATCH,
re-read the current cohostIds and compare it with the IDs supplied from the
earlier read; detect drift and avoid overwriting concurrent changes, or
explicitly enforce the documented single-writer assumption if that is the
established contract. Keep the repair scope limited to setCohostIds and preserve
its deduplication behavior.

---

Nitpick comments:
In `@src/commands/cohosts.ts`:
- Around line 154-157: Update the cohosts remove flow around getCohostState to
avoid fetching cohost IDs twice: call getCohostRequests and getCohostIds
directly, merge their results with mergeCohostState, and retain the raw IDs for
the stale-repair closure. Preserve the existing remove behavior while
eliminating the redundant getCohostState-triggered read.

In `@src/commands/events.ts`:
- Around line 233-242: Extract shared createEventAndInviteCohosts and
planCohostInvites helpers in events.ts, preserving the existing event-ID
fallback order and missing-ID error. Replace the repeated single-create,
series-create, and clone runtime invitation blocks with the shared helper, and
use planCohostInvites for both create and clone dry-run previews so endpoint and
parameter construction remain consistent.

In `@src/lib/api/endpoints.ts`:
- Around line 194-226: Replace the legacy .passthrough() calls in
CreateCohostRequestResponseSchema, DeleteCohostRequestResponseSchema,
RemoveCohostResponseSchema, GenerateEventCohostLinkResponseSchema, and
RevokeEventCohostLinkResponseSchema with z.looseObject(), preserving the
existing fields including optional path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7f29dfd8-6a7f-4aed-a609-ada88083c37a

📥 Commits

Reviewing files that changed from the base of the PR and between d2c8054 and 35a78f4.

📒 Files selected for processing (13)
  • docs/plans/2026-08-01-cohost-lifecycle-and-links.md
  • skills/partiful/SKILL.md
  • skills/partiful/references/events.md
  • skills/partiful/references/guests-invitations-and-cohosts.md
  • src/commands/cohosts.ts
  • src/commands/events.ts
  • src/commands/schema.ts
  • src/lib/api/endpoints.ts
  • src/lib/cohosts.ts
  • src/lib/http.ts
  • tests/cohosts.test.js
  • tests/http.test.js
  • tests/schema-api.test.js

Comment thread src/commands/cohosts.ts
Comment on lines +116 to +136
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}` });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Reuse inviteCohostBatch instead of reimplementing its loop.

This loop duplicates inviteCohostBatch from src/lib/cohosts.ts, with one difference: it reports failures as error: String(error) instead of error instanceof Error ? error.message : String(error). That means the error-message format tested for inviteCohostBatch at tests/cohosts.test.js (lines 127-136) is not the format that actually runs for the cohosts add command, and any future change to the batch-invite behavior in the library has to be duplicated here by hand.

inviteCohostBatch already accepts a per-user repairStale(userId) hook with the same "only call it when stale" guard this code implements manually, so the stale-ID-removal closure over currentIds can be passed straight through.

♻️ Proposed refactor to reuse inviteCohostBatch
-        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) });
-          }
-        }
+        const call = callable(token, config, verbose);
+        const repairStale = async (userId: string) => {
+          currentIds = currentIds.filter((id) => id !== userId);
+          await setCohostIds(eventId, currentIds, token, verbose);
+        };
+        const { succeeded, failed } = await inviteCohostBatch(eventId, ids, states, call, repairStale);

This requires importing inviteCohostBatch from ../lib/cohosts.js and no longer needs the direct inviteCohost import in this command.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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}` });
const call = callable(token, config, verbose);
const repairStale = async (userId: string) => {
currentIds = currentIds.filter((id) => id !== userId);
await setCohostIds(eventId, currentIds, token, verbose);
};
const { succeeded, failed } = await inviteCohostBatch(eventId, ids, states, call, repairStale);
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}` });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/cohosts.ts` around lines 116 - 136, Replace the manual
invitation loop in the cohosts command with the shared inviteCohostBatch
function from ../lib/cohosts.js, removing the direct inviteCohost import. Pass
the existing event, IDs, state, call, and currentIds stale-repair behavior
through inviteCohostBatch, preserving its standardized error formatting and the
existing ApiError/jsonOutput handling.

Comment thread src/lib/cohosts.ts
Comment on lines +113 to 123
/** @deprecated Lifecycle commands must use canonical callables, not raw membership writes. */
export async function setCohostIds(
eventId: string,
ids: string[],
token: string,
verbose = false,
): Promise<void> {
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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Blind overwrite of cohostIds during stale repair can lose concurrent updates.

setCohostIds writes the full cohostIds array back to Firestore based on a value read earlier (existingIds/currentIds in src/commands/cohosts.ts, used by both the add and remove command's stale-repair hooks). Nothing checks that cohostIds is still what was read before the PATCH is issued. If another writer changes cohostIds between the read and this write, that change is silently discarded because the PATCH replaces the whole field.

This function is documented as the narrow exception for repairing legacy corruption, so the blast radius is limited to that path, but a lost update here would silently drop a cohost ID that another actor just added or removed. Consider re-reading cohostIds immediately before the write and detecting drift, or confirming (and documenting) that this repair path is expected to run under a single-writer assumption.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/cohosts.ts` around lines 113 - 123, Prevent setCohostIds from blindly
replacing a concurrently changed cohostIds value during stale repair. Before the
PATCH, re-read the current cohostIds and compare it with the IDs supplied from
the earlier read; detect drift and avoid overwriting concurrent changes, or
explicitly enforce the documented single-writer assumption if that is the
established contract. Keep the repair scope limited to setCohostIds and preserve
its deduplication behavior.

@KalebCole
KalebCole merged commit 59ed7c6 into main Aug 1, 2026
1 check passed
@KalebCole
KalebCole deleted the fix/cohost-lifecycle-links branch August 1, 2026 17:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P1] Fix cohost lifecycle and add invite-link support

1 participant