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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { registerAuthCommands } from './commands/auth.js';
import { registerEventsCommands } from './commands/events.js';
import { registerGuestsCommands } from './commands/guests.js';
import { registerContactsCommands } from './commands/contacts.js';
import { registerCohostsCommands } from './commands/cohosts.js';
import { registerBlastsCommands } from './commands/blasts.js';
import { registerCloneHelper } from './helpers/clone.js';
import { registerWatchHelper } from './helpers/watch.js';
Expand Down Expand Up @@ -35,6 +36,7 @@ export function run() {
registerEventsCommands(program);
registerGuestsCommands(program);
registerContactsCommands(program);
registerCohostsCommands(program);
registerBlastsCommands(program);
registerCloneHelper(program);
registerWatchHelper(program);
Expand Down
131 changes: 131 additions & 0 deletions src/commands/cohosts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* Cohosts commands: list, add, remove
*/

import { loadConfig, getValidToken, wrapPayload } from '../lib/auth.js';
import { apiRequest } from '../lib/http.js';
import { resolveCohostNames, getCohostIds, setCohostIds } from '../lib/cohosts.js';
import { jsonOutput, jsonError } from '../lib/output.js';
import { PartifulError } from '../lib/errors.js';

export function registerCohostsCommands(program) {
const cohosts = program.command('cohosts').description('Manage event co-hosts');

cohosts
.command('list')
.description('List co-hosts for an event')
.argument('<eventId>', 'Event ID')
.action(async (eventId, opts, cmd) => {
const globalOpts = cmd.optsWithGlobals();
try {
const config = loadConfig();
const token = await getValidToken(config);

const ids = await getCohostIds(eventId, token, globalOpts.verbose);
if (ids.length === 0) {
jsonOutput([], { eventId, count: 0 });
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Pass global options into every jsonOutput call in this file.

On Line 26, Line 40, Line 79, Line 86, Line 114, and Line 120, jsonOutput is called without globalOpts, so global flags like --output are ignored for cohost commands.

Proposed patch
-          jsonOutput([], { eventId, count: 0 });
+          jsonOutput([], { eventId, count: 0 }, globalOpts);
...
-        jsonOutput(result, { eventId, count: result.length });
+        jsonOutput(result, { eventId, count: result.length }, globalOpts);
...
-          jsonOutput({ dryRun: true, eventId, currentCohosts: currentIds, newCohosts: newIds });
+          jsonOutput({ dryRun: true, eventId, currentCohosts: currentIds, newCohosts: newIds }, {}, globalOpts);
...
-        jsonOutput({ eventId, added, total: newIds.length, url: `https://partiful.com/e/${eventId}` });
+        jsonOutput({ eventId, added, total: newIds.length, url: `https://partiful.com/e/${eventId}` }, {}, globalOpts);
...
-          jsonOutput({ dryRun: true, eventId, removing: opts.userId, remaining: newIds });
+          jsonOutput({ dryRun: true, eventId, removing: opts.userId, remaining: newIds }, {}, globalOpts);
...
-        jsonOutput({ eventId, removed: opts.userId, remaining: newIds.length, url: `https://partiful.com/e/${eventId}` });
+        jsonOutput({ eventId, removed: opts.userId, remaining: newIds.length, url: `https://partiful.com/e/${eventId}` }, {}, globalOpts);

Also applies to: 40-40, 79-79, 86-86, 114-114, 120-120

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/cohosts.js` at line 26, Several jsonOutput(...) calls in this
file are invoked without passing the global options, so global flags like
--output are ignored; update every jsonOutput invocation (e.g., the calls that
currently pass option objects like { eventId, count: 0 }) to merge in the
globalOpts before the local keys (use the spread operator or Object.assign, e.g.
jsonOutput(data, { ...globalOpts, eventId, count: 0 }) or jsonOutput(data,
Object.assign({}, globalOpts, { eventId, count: 0 }))). Locate all uses of
jsonOutput in this module (including those around the cohost command handlers)
and ensure each second-argument options object includes globalOpts merged with
the existing properties.

return;
}

// Cross-reference with contacts for names
const contactsPayload = { data: wrapPayload(config, { params: {}, amplitudeSessionId: Date.now(), userId: config.userId }) };
const contactsResult = await apiRequest('POST', '/getContacts', token, contactsPayload, globalOpts.verbose);
const allContacts = contactsResult.result?.data || [];

const result = ids.map(id => {
const contact = allContacts.find(c => c.userId === id);
return { userId: id, name: contact?.name || null };
});
Comment on lines +35 to +38
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

cohosts list output is missing required identifying fields/metadata.

Line 35-Line 38 only returns { userId, name }, but the stated objective calls for richer identifiers (e.g., username, phone, host) and metadata (e.g., added-at when available).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/cohosts.js` around lines 35 - 38, The current mapping that
builds result only returns { userId, name } which omits required identifiers and
metadata; update the mapping in the result construction (the ids.map callback
referencing ids, allContacts, contact) to include additional fields such as
username (contact.username || null), phone (contact.phone || null), host
(contact.host || false or contact.isHost), and addedAt (contact.addedAt || null)
or pull added-at from the cohosts record if stored elsewhere; ensure each
property uses safe fallbacks (null/false) so the cohosts list returns the richer
identifier set and metadata expected by the command.


jsonOutput(result, { eventId, count: result.length });
} catch (e) {
if (e instanceof PartifulError) jsonError(e.message, e.exitCode, e.type, e.details);
else jsonError(e.message);
}
});

cohosts
.command('add')
.description('Add co-hosts to an event')
.argument('<eventId>', 'Event ID')
.option('--name <names...>', 'Co-host names (resolved from contacts)')
.option('--user-id <userIds...>', 'Co-host user IDs (direct)')
Comment on lines +51 to +52
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

cohosts add is missing phone/username input paths.

Only --name and --user-id are supported on Line 51-Line 52. The objective includes adding co-hosts by phone number or Partiful username.

Suggested command-surface extension
   cohosts
     .command('add')
     .description('Add co-hosts to an event')
     .argument('<eventId>', 'Event ID')
     .option('--name <names...>', 'Co-host names (resolved from contacts)')
+    .option('--phone <phones...>', 'Co-host phone numbers')
+    .option('--username <usernames...>', 'Co-host Partiful usernames')
     .option('--user-id <userIds...>', 'Co-host user IDs (direct)')
📝 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
.option('--name <names...>', 'Co-host names (resolved from contacts)')
.option('--user-id <userIds...>', 'Co-host user IDs (direct)')
.option('--name <names...>', 'Co-host names (resolved from contacts)')
.option('--phone <phones...>', 'Co-host phone numbers')
.option('--username <usernames...>', 'Co-host Partiful usernames')
.option('--user-id <userIds...>', 'Co-host user IDs (direct)')
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/cohosts.js` around lines 51 - 52, The cohosts add command only
accepts --name and --user-id; add two new CLI options .option('--phone
<phones...>', 'Co-host phone numbers') and .option('--username <usernames...>',
'Co-host Partiful usernames') to the same command definition where
.option('--name <names...>') and .option('--user-id <userIds...>') are declared,
then update the cohosts add handler (the function that processes the add command
/ addCohosts handler) to accept and normalize inputs from phone and username,
resolve them into the same internal cohost representation as names/userIds,
validate duplicates, and pass them into the existing creation/resolution logic
so phone/username flows behave equivalently to name/user-id paths.

.action(async (eventId, opts, cmd) => {
const globalOpts = cmd.optsWithGlobals();
try {
if (!opts.name && !opts.userId) {
jsonError('Provide --name or --user-id to specify co-hosts', 3, 'validation_error');
return;
}

const config = loadConfig();
const token = await getValidToken(config);

const currentIds = await getCohostIds(eventId, token, globalOpts.verbose);
const newIds = [...currentIds];

// Resolve names
const resolved = await resolveCohostNames(opts.name, token, config, globalOpts.verbose);
for (const id of resolved) {
if (!newIds.includes(id)) newIds.push(id);
}

// Add direct user IDs
for (const id of (opts.userId || [])) {
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;
}

if (globalOpts.dryRun) {
jsonOutput({ dryRun: true, eventId, currentCohosts: currentIds, newCohosts: newIds });
return;
}

await setCohostIds(eventId, newIds, token, globalOpts.verbose);

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.message);
}
});

cohosts
.command('remove')
.description('Remove a co-host from an event')
.argument('<eventId>', 'Event ID')
.requiredOption('--user-id <userId>', 'User ID of the co-host to remove')
.action(async (eventId, opts, cmd) => {
const globalOpts = cmd.optsWithGlobals();
try {
const config = loadConfig();
const token = await getValidToken(config);

const currentIds = await getCohostIds(eventId, token, globalOpts.verbose);

if (!currentIds.includes(opts.userId)) {
jsonError(`User ${opts.userId} is not a co-host of this event`, 4, 'not_found');
return;
}

const newIds = currentIds.filter(id => id !== opts.userId);

if (globalOpts.dryRun) {
jsonOutput({ dryRun: true, eventId, removing: opts.userId, remaining: newIds });
return;
}

await setCohostIds(eventId, newIds, token, globalOpts.verbose);

jsonOutput({ eventId, removed: opts.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.message);
}
});
}
28 changes: 23 additions & 5 deletions src/commands/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import { loadConfig, getValidToken, wrapPayload } from '../lib/auth.js';
import { resolveCohostNames } from '../lib/cohosts.js';
import { fetchCatalog, searchPosters, buildPosterImage } from '../lib/posters.js';
import { apiRequest, firestoreRequest } from '../lib/http.js';
import { parseDateTime, stripMarkdown, formatDate } from '../lib/dates.js';
Expand Down Expand Up @@ -183,6 +184,7 @@ export function registerEventsCommands(program) {
.option('--link-text <text...>', 'Display text for link (paired with --link by position)')
.option('--template <name>', 'Create from a saved template')
.option('--var <vars...>', 'Template variables (key=value)')
.option('--cohost <names...>', 'Co-host names (resolved from contacts)')
.action(async (opts, cmd) => {
const globalOpts = cmd.optsWithGlobals();
try {
Expand Down Expand Up @@ -331,16 +333,18 @@ export function registerEventsCommands(program) {
}
}

const cohostIds = await resolveCohostNames(opts.cohost, token, config, globalOpts.verbose);

const payload = {
data: wrapPayload(config, {
params: { event, cohostIds: [] },
params: { event, cohostIds },
amplitudeSessionId: Date.now(),
userId: config.userId,
}),
};

if (globalOpts.dryRun) {
jsonOutput({ dryRun: true, endpoint: '/createEvent', payload, ...(opts.repeat ? { series: { repeat: opts.repeat, count: opts.count } } : {}) });
jsonOutput({ dryRun: true, endpoint: '/createEvent', payload, cohostsResolved: cohostIds.length, ...(opts.repeat ? { series: { repeat: opts.repeat, count: opts.count } } : {}) });
return;
}

Expand All @@ -360,7 +364,7 @@ export function registerEventsCommands(program) {
const seriesEvent = { ...event, startDate: d.toISOString() };
const seriesPayload = {
data: wrapPayload(config, {
params: { event: seriesEvent, cohostIds: [] },
params: { event: seriesEvent, cohostIds },
amplitudeSessionId: Date.now(),
userId: config.userId,
}),
Expand Down Expand Up @@ -409,6 +413,7 @@ export function registerEventsCommands(program) {
.option('--image <path>', 'Upload and set custom image')
.option('--link <url...>', 'Link URL (repeatable)')
.option('--link-text <text...>', 'Display text for link (paired with --link by position)')
.option('--cohost <names...>', 'Co-host names (resolved from contacts)')
.action(async (eventId, opts, cmd) => {
const globalOpts = cmd.optsWithGlobals();
try {
Expand Down Expand Up @@ -510,8 +515,18 @@ export function registerEventsCommands(program) {
}
}

if (opts.cohost && opts.cohost.length > 0) {
const resolvedIds = await resolveCohostNames(opts.cohost, token, config, globalOpts.verbose);
if (resolvedIds.length > 0) {
fields.cohostIds = {
arrayValue: { values: resolvedIds.map(id => ({ stringValue: id })) }
};
updateFields.push('cohostIds');
}
}
Comment on lines +518 to +526
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Same deduplication gap in update command.

The events update cohost resolution also lacks duplicate checking, inconsistent with cohosts add.

🔧 Proposed fix
           if (match && match.userId) {
-            resolvedIds.push(match.userId);
+            if (!resolvedIds.includes(match.userId)) resolvedIds.push(match.userId);
           } else {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/events.js` around lines 532 - 552, In the events update block
handling opts.cohost, avoid pushing duplicate userIds into resolvedIds by
deduplicating as you resolve matches (e.g., track already-added IDs with a Set
or check resolvedIds.includes before push) so the final
fields.cohostIds.arrayValue.values contains unique stringValue entries; ensure
this mirrors the dedup logic used by the cohosts add flow and still pushes
'cohostIds' to updateFields only when there is at least one unique id.


if (updateFields.length === 0) {
jsonError('No fields to update. Use --title, --location, --description, --date, --end-date, --capacity, --link, --poster, --poster-search, or --image', 3, 'validation_error');
jsonError('No fields to update. Use --title, --location, --description, --date, --end-date, --capacity, --link, --poster, --poster-search, --image, or --cohost', 3, 'validation_error');
return;
}

Expand Down Expand Up @@ -553,6 +568,7 @@ export function registerEventsCommands(program) {
.option('--image <path>', 'Override with custom image')
.option('--link <url...>', 'Override links (repeatable)')
.option('--link-text <text...>', 'Display text for links')
.option('--cohost <names...>', 'Co-host names (resolved from contacts)')
.action(async (eventId, opts, cmd) => {
const globalOpts = cmd.optsWithGlobals();
try {
Expand Down Expand Up @@ -717,10 +733,12 @@ export function registerEventsCommands(program) {
event.image = src.image;
}

const cohostIds = await resolveCohostNames(opts.cohost, token, config, globalOpts.verbose);

// 4. Build API payload
const payload = {
data: wrapPayload(config, {
params: { event, cohostIds: [] },
params: { event, cohostIds },
amplitudeSessionId: Date.now(),
userId: config.userId,
}),
Expand Down
23 changes: 23 additions & 0 deletions src/commands/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const SCHEMAS = {
'--poster': { type: 'string', required: false, description: 'Built-in poster ID' },
'--poster-search': { type: 'string', required: false, description: 'Search poster library, use best match' },
'--image': { type: 'string', required: false, description: 'Custom image file path or URL to upload' },
'--cohost': { type: 'string[]', required: false, description: 'Co-host names (resolved from contacts)' },
},
},
'events.update': {
Expand All @@ -45,6 +46,7 @@ const SCHEMAS = {
'--poster': { type: 'string', required: false, description: 'Built-in poster ID' },
'--poster-search': { type: 'string', required: false, description: 'Search poster library, use best match' },
'--image': { type: 'string', required: false, description: 'Custom image file path or URL to upload' },
'--cohost': { type: 'string[]', required: false, description: 'Co-host names (resolved from contacts)' },
},
},
'events.cancel': {
Expand Down Expand Up @@ -76,6 +78,27 @@ const SCHEMAS = {
'--limit': { type: 'integer', required: false, default: 20 },
},
},
'cohosts.list': {
command: 'cohosts list <eventId>',
parameters: {
eventId: { type: 'string', required: true, positional: true, description: 'Event ID' },
},
},
'cohosts.add': {
command: 'cohosts add <eventId>',
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' },
},
},
'cohosts.remove': {
command: 'cohosts remove <eventId>',
parameters: {
eventId: { type: 'string', required: true, positional: true, description: 'Event ID' },
'--user-id': { type: 'string', required: true, description: 'User ID to remove' },
},
},
'blasts.send': {
command: 'blasts send <eventId>',
parameters: {
Expand Down
62 changes: 62 additions & 0 deletions src/lib/cohosts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Shared co-host helpers: contact resolution, Firestore read/write.
*/

import { apiRequest, firestoreRequest } from './http.js';
import { wrapPayload } from './auth.js';

/**
* Resolve co-host names to Partiful user IDs via the contacts API.
* Tries exact match first, then substring. Warns on stderr for misses.
* @returns {string[]} resolved user IDs
*/
export async function resolveCohostNames(names, token, config, verbose = false) {
if (!names || names.length === 0) return [];

const payload = {
data: wrapPayload(config, {
params: {},
amplitudeSessionId: Date.now(),
userId: config.userId,
}),
};
const result = await apiRequest('POST', '/getContacts', token, payload, verbose);
const contacts = result.result?.data || [];

const ids = [];
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;
}

/**
* Read cohostIds array from a Firestore event document.
* @returns {string[]}
*/
export async function getCohostIds(eventId, token, verbose = false) {
const doc = await firestoreRequest('GET', eventId, null, token, [], verbose);
const values = doc.fields?.cohostIds?.arrayValue?.values || [];
return values.map(v => v.stringValue).filter(Boolean);
}

/**
* Write cohostIds array to a Firestore event document.
*/
export async function setCohostIds(eventId, ids, token, verbose = false) {
const unique = [...new Set(ids.filter(Boolean))];
const fields = {
cohostIds: {
arrayValue: { values: unique.map(id => ({ stringValue: id })) },
},
};
await firestoreRequest('PATCH', eventId, { fields }, token, ['cohostIds'], verbose);
}