From b1975bf542195d6dc3a243da0938039aeff1a226 Mon Sep 17 00:00:00 2001 From: Kaleb Cole Date: Thu, 26 Mar 2026 12:31:17 -0700 Subject: [PATCH 1/3] feat: add co-host management (#24) --- src/cli.js | 2 + src/commands/cohosts.js | 169 ++++++++++++++++++++++++++++++++++++++++ src/commands/events.js | 69 ++++++++++++++-- src/commands/schema.js | 23 ++++++ 4 files changed, 258 insertions(+), 5 deletions(-) create mode 100644 src/commands/cohosts.js diff --git a/src/cli.js b/src/cli.js index e600c94..b417cf8 100644 --- a/src/cli.js +++ b/src/cli.js @@ -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'; @@ -35,6 +36,7 @@ export function run() { registerEventsCommands(program); registerGuestsCommands(program); registerContactsCommands(program); + registerCohostsCommands(program); registerBlastsCommands(program); registerCloneHelper(program); registerWatchHelper(program); diff --git a/src/commands/cohosts.js b/src/commands/cohosts.js new file mode 100644 index 0000000..69ae6a5 --- /dev/null +++ b/src/commands/cohosts.js @@ -0,0 +1,169 @@ +/** + * Cohosts commands: list, add, remove + */ + +import { loadConfig, getValidToken, wrapPayload } from '../lib/auth.js'; +import { apiRequest, firestoreRequest } from '../lib/http.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('', 'Event ID') + .action(async (eventId, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + try { + const config = loadConfig(); + const token = await getValidToken(config); + + // Fetch event via Firestore to get cohostIds + const eventDoc = await firestoreRequest('GET', eventId, null, token, [], globalOpts.verbose); + const cohostIdsField = eventDoc.fields?.cohostIds?.arrayValue?.values || []; + const cohostIds = cohostIdsField.map(v => v.stringValue).filter(Boolean); + + if (cohostIds.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 contactsResult = await apiRequest('POST', '/getContacts', token, contactsPayload, globalOpts.verbose); + const allContacts = contactsResult.result?.data || []; + + const result = cohostIds.map(id => { + const contact = allContacts.find(c => c.userId === id); + return { userId: id, name: contact?.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.message); + } + }); + + cohosts + .command('add') + .description('Add co-hosts to an event') + .argument('', 'Event ID') + .option('--name ', 'Co-host names (resolved from contacts)') + .option('--user-id ', 'Co-host user IDs (direct)') + .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); + + // Get current cohostIds + const eventDoc = await firestoreRequest('GET', eventId, null, token, [], globalOpts.verbose); + const currentField = eventDoc.fields?.cohostIds?.arrayValue?.values || []; + const currentIds = currentField.map(v => v.stringValue).filter(Boolean); + + const newIds = [...currentIds]; + + // Resolve names + if (opts.name && opts.name.length > 0) { + 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 || []; + for (const name of opts.name) { + const q = name.toLowerCase(); + const match = allContacts.find(c => (c.name || '').toLowerCase() === q) || allContacts.find(c => (c.name || '').toLowerCase().includes(q)); + if (match && match.userId) { + if (!newIds.includes(match.userId)) newIds.push(match.userId); + } else { + process.stderr.write(`Warning: could not resolve co-host "${name}" from contacts — skipping\n`); + } + } + } + + // Add direct user IDs + if (opts.userId && opts.userId.length > 0) { + for (const id of opts.userId) { + if (!newIds.includes(id)) newIds.push(id); + } + } + + const fields = { + cohostIds: { + arrayValue: { values: newIds.map(id => ({ stringValue: id })) } + } + }; + + if (globalOpts.dryRun) { + jsonOutput({ dryRun: true, eventId, currentCohosts: currentIds, newCohosts: newIds, body: { fields } }); + return; + } + + await firestoreRequest('PATCH', eventId, { fields }, token, ['cohostIds'], globalOpts.verbose); + + const added = newIds.filter(id => !currentIds.includes(id)); + 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('', 'Event ID') + .requiredOption('--user-id ', '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); + + // Get current cohostIds + const eventDoc = await firestoreRequest('GET', eventId, null, token, [], globalOpts.verbose); + const currentField = eventDoc.fields?.cohostIds?.arrayValue?.values || []; + const currentIds = currentField.map(v => v.stringValue).filter(Boolean); + + 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); + const fields = { + cohostIds: { + arrayValue: { values: newIds.map(id => ({ stringValue: id })) } + } + }; + + if (globalOpts.dryRun) { + jsonOutput({ dryRun: true, eventId, removing: opts.userId, remaining: newIds }); + return; + } + + await firestoreRequest('PATCH', eventId, { fields }, token, ['cohostIds'], 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); + } + }); +} diff --git a/src/commands/events.js b/src/commands/events.js index 96b9907..57c3b73 100644 --- a/src/commands/events.js +++ b/src/commands/events.js @@ -183,6 +183,7 @@ export function registerEventsCommands(program) { .option('--link-text ', 'Display text for link (paired with --link by position)') .option('--template ', 'Create from a saved template') .option('--var ', 'Template variables (key=value)') + .option('--cohost ', 'Co-host names (resolved from contacts)') .action(async (opts, cmd) => { const globalOpts = cmd.optsWithGlobals(); try { @@ -331,16 +332,33 @@ export function registerEventsCommands(program) { } } + // Resolve co-host names to IDs + const cohostIds = []; + if (opts.cohost && opts.cohost.length > 0) { + 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 || []; + for (const name of opts.cohost) { + const q = name.toLowerCase(); + const match = allContacts.find(c => (c.name || '').toLowerCase() === q) || allContacts.find(c => (c.name || '').toLowerCase().includes(q)); + if (match && match.userId) { + cohostIds.push(match.userId); + } else { + process.stderr.write(`Warning: could not resolve co-host "${name}" from contacts — skipping\n`); + } + } + } + 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; } @@ -360,7 +378,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, }), @@ -409,6 +427,7 @@ export function registerEventsCommands(program) { .option('--image ', 'Upload and set custom image') .option('--link ', 'Link URL (repeatable)') .option('--link-text ', 'Display text for link (paired with --link by position)') + .option('--cohost ', 'Co-host names (resolved from contacts)') .action(async (eventId, opts, cmd) => { const globalOpts = cmd.optsWithGlobals(); try { @@ -510,8 +529,30 @@ export function registerEventsCommands(program) { } } + if (opts.cohost && opts.cohost.length > 0) { + 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 resolvedIds = []; + for (const name of opts.cohost) { + const q = name.toLowerCase(); + const match = allContacts.find(c => (c.name || '').toLowerCase() === q) || allContacts.find(c => (c.name || '').toLowerCase().includes(q)); + if (match && match.userId) { + resolvedIds.push(match.userId); + } else { + process.stderr.write(`Warning: could not resolve co-host "${name}" from contacts — skipping\n`); + } + } + if (resolvedIds.length > 0) { + fields.cohostIds = { + arrayValue: { values: resolvedIds.map(id => ({ stringValue: id })) } + }; + updateFields.push('cohostIds'); + } + } + 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; } @@ -553,6 +594,7 @@ export function registerEventsCommands(program) { .option('--image ', 'Override with custom image') .option('--link ', 'Override links (repeatable)') .option('--link-text ', 'Display text for links') + .option('--cohost ', 'Co-host names (resolved from contacts)') .action(async (eventId, opts, cmd) => { const globalOpts = cmd.optsWithGlobals(); try { @@ -717,10 +759,27 @@ export function registerEventsCommands(program) { event.image = src.image; } + // Resolve co-host names to IDs + const cohostIds = []; + if (opts.cohost && opts.cohost.length > 0) { + 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 || []; + for (const name of opts.cohost) { + const q = name.toLowerCase(); + const match = allContacts.find(c => (c.name || '').toLowerCase() === q) || allContacts.find(c => (c.name || '').toLowerCase().includes(q)); + if (match && match.userId) { + cohostIds.push(match.userId); + } else { + process.stderr.write(`Warning: could not resolve co-host "${name}" from contacts — skipping\n`); + } + } + } + // 4. Build API payload const payload = { data: wrapPayload(config, { - params: { event, cohostIds: [] }, + params: { event, cohostIds }, amplitudeSessionId: Date.now(), userId: config.userId, }), diff --git a/src/commands/schema.js b/src/commands/schema.js index a2ca7cc..ed86f98 100644 --- a/src/commands/schema.js +++ b/src/commands/schema.js @@ -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': { @@ -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': { @@ -76,6 +78,27 @@ const SCHEMAS = { '--limit': { type: 'integer', required: false, default: 20 }, }, }, + 'cohosts.list': { + command: 'cohosts list ', + parameters: { + eventId: { type: 'string', required: true, positional: true, description: 'Event ID' }, + }, + }, + 'cohosts.add': { + 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' }, + }, + }, + 'cohosts.remove': { + command: 'cohosts remove ', + 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 ', parameters: { From ebf533bb654716d71e90006b4a8d29ba55b1de10 Mon Sep 17 00:00:00 2001 From: Kaleb Cole Date: Thu, 26 Mar 2026 15:30:28 -0700 Subject: [PATCH 2/3] refactor: extract cohost helpers, deduplicate resolution logic --- src/commands/cohosts.js | 77 +++++++++-------------------------------- src/commands/events.js | 49 +++----------------------- src/lib/cohosts.js | 61 ++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 105 deletions(-) create mode 100644 src/lib/cohosts.js diff --git a/src/commands/cohosts.js b/src/commands/cohosts.js index 69ae6a5..95b1c1e 100644 --- a/src/commands/cohosts.js +++ b/src/commands/cohosts.js @@ -3,7 +3,8 @@ */ import { loadConfig, getValidToken, wrapPayload } from '../lib/auth.js'; -import { apiRequest, firestoreRequest } from '../lib/http.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'; @@ -20,12 +21,8 @@ export function registerCohostsCommands(program) { const config = loadConfig(); const token = await getValidToken(config); - // Fetch event via Firestore to get cohostIds - const eventDoc = await firestoreRequest('GET', eventId, null, token, [], globalOpts.verbose); - const cohostIdsField = eventDoc.fields?.cohostIds?.arrayValue?.values || []; - const cohostIds = cohostIdsField.map(v => v.stringValue).filter(Boolean); - - if (cohostIds.length === 0) { + const ids = await getCohostIds(eventId, token, globalOpts.verbose); + if (ids.length === 0) { jsonOutput([], { eventId, count: 0 }); return; } @@ -35,7 +32,7 @@ export function registerCohostsCommands(program) { const contactsResult = await apiRequest('POST', '/getContacts', token, contactsPayload, globalOpts.verbose); const allContacts = contactsResult.result?.data || []; - const result = cohostIds.map(id => { + const result = ids.map(id => { const contact = allContacts.find(c => c.userId === id); return { userId: id, name: contact?.name || null }; }); @@ -64,56 +61,29 @@ export function registerCohostsCommands(program) { const config = loadConfig(); const token = await getValidToken(config); - // Get current cohostIds - const eventDoc = await firestoreRequest('GET', eventId, null, token, [], globalOpts.verbose); - const currentField = eventDoc.fields?.cohostIds?.arrayValue?.values || []; - const currentIds = currentField.map(v => v.stringValue).filter(Boolean); - + const currentIds = await getCohostIds(eventId, token, globalOpts.verbose); const newIds = [...currentIds]; // Resolve names - if (opts.name && opts.name.length > 0) { - 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 || []; - for (const name of opts.name) { - const q = name.toLowerCase(); - const match = allContacts.find(c => (c.name || '').toLowerCase() === q) || allContacts.find(c => (c.name || '').toLowerCase().includes(q)); - if (match && match.userId) { - if (!newIds.includes(match.userId)) newIds.push(match.userId); - } else { - process.stderr.write(`Warning: could not resolve co-host "${name}" from contacts — skipping\n`); - } - } + 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 - if (opts.userId && opts.userId.length > 0) { - for (const id of opts.userId) { - if (!newIds.includes(id)) newIds.push(id); - } + for (const id of (opts.userId || [])) { + if (!newIds.includes(id)) newIds.push(id); } - const fields = { - cohostIds: { - arrayValue: { values: newIds.map(id => ({ stringValue: id })) } - } - }; - if (globalOpts.dryRun) { - jsonOutput({ dryRun: true, eventId, currentCohosts: currentIds, newCohosts: newIds, body: { fields } }); + jsonOutput({ dryRun: true, eventId, currentCohosts: currentIds, newCohosts: newIds }); return; } - await firestoreRequest('PATCH', eventId, { fields }, token, ['cohostIds'], globalOpts.verbose); + await setCohostIds(eventId, newIds, token, globalOpts.verbose); const added = newIds.filter(id => !currentIds.includes(id)); - jsonOutput({ - eventId, - added, - total: newIds.length, - url: `https://partiful.com/e/${eventId}`, - }); + 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); @@ -131,10 +101,7 @@ export function registerCohostsCommands(program) { const config = loadConfig(); const token = await getValidToken(config); - // Get current cohostIds - const eventDoc = await firestoreRequest('GET', eventId, null, token, [], globalOpts.verbose); - const currentField = eventDoc.fields?.cohostIds?.arrayValue?.values || []; - const currentIds = currentField.map(v => v.stringValue).filter(Boolean); + 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'); @@ -142,25 +109,15 @@ export function registerCohostsCommands(program) { } const newIds = currentIds.filter(id => id !== opts.userId); - const fields = { - cohostIds: { - arrayValue: { values: newIds.map(id => ({ stringValue: id })) } - } - }; if (globalOpts.dryRun) { jsonOutput({ dryRun: true, eventId, removing: opts.userId, remaining: newIds }); return; } - await firestoreRequest('PATCH', eventId, { fields }, token, ['cohostIds'], globalOpts.verbose); + await setCohostIds(eventId, newIds, token, globalOpts.verbose); - 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}` }); } catch (e) { if (e instanceof PartifulError) jsonError(e.message, e.exitCode, e.type, e.details); else jsonError(e.message); diff --git a/src/commands/events.js b/src/commands/events.js index 57c3b73..b34cb8d 100644 --- a/src/commands/events.js +++ b/src/commands/events.js @@ -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'; @@ -332,22 +333,7 @@ export function registerEventsCommands(program) { } } - // Resolve co-host names to IDs - const cohostIds = []; - if (opts.cohost && opts.cohost.length > 0) { - 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 || []; - for (const name of opts.cohost) { - const q = name.toLowerCase(); - const match = allContacts.find(c => (c.name || '').toLowerCase() === q) || allContacts.find(c => (c.name || '').toLowerCase().includes(q)); - if (match && match.userId) { - cohostIds.push(match.userId); - } else { - process.stderr.write(`Warning: could not resolve co-host "${name}" from contacts — skipping\n`); - } - } - } + const cohostIds = await resolveCohostNames(opts.cohost, token, config, globalOpts.verbose); const payload = { data: wrapPayload(config, { @@ -530,19 +516,7 @@ export function registerEventsCommands(program) { } if (opts.cohost && opts.cohost.length > 0) { - 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 resolvedIds = []; - for (const name of opts.cohost) { - const q = name.toLowerCase(); - const match = allContacts.find(c => (c.name || '').toLowerCase() === q) || allContacts.find(c => (c.name || '').toLowerCase().includes(q)); - if (match && match.userId) { - resolvedIds.push(match.userId); - } else { - process.stderr.write(`Warning: could not resolve co-host "${name}" from contacts — skipping\n`); - } - } + const resolvedIds = await resolveCohostNames(opts.cohost, token, config, globalOpts.verbose); if (resolvedIds.length > 0) { fields.cohostIds = { arrayValue: { values: resolvedIds.map(id => ({ stringValue: id })) } @@ -759,22 +733,7 @@ export function registerEventsCommands(program) { event.image = src.image; } - // Resolve co-host names to IDs - const cohostIds = []; - if (opts.cohost && opts.cohost.length > 0) { - 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 || []; - for (const name of opts.cohost) { - const q = name.toLowerCase(); - const match = allContacts.find(c => (c.name || '').toLowerCase() === q) || allContacts.find(c => (c.name || '').toLowerCase().includes(q)); - if (match && match.userId) { - cohostIds.push(match.userId); - } else { - process.stderr.write(`Warning: could not resolve co-host "${name}" from contacts — skipping\n`); - } - } - } + const cohostIds = await resolveCohostNames(opts.cohost, token, config, globalOpts.verbose); // 4. Build API payload const payload = { diff --git a/src/lib/cohosts.js b/src/lib/cohosts.js new file mode 100644 index 0000000..ae22b50 --- /dev/null +++ b/src/lib/cohosts.js @@ -0,0 +1,61 @@ +/** + * 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) { + 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 fields = { + cohostIds: { + arrayValue: { values: ids.map(id => ({ stringValue: id })) }, + }, + }; + await firestoreRequest('PATCH', eventId, { fields }, token, ['cohostIds'], verbose); +} From 5480aaa97bb7196e7f2fea5a5daf6121cc9a42de Mon Sep 17 00:00:00 2001 From: Kaleb Cole Date: Thu, 26 Mar 2026 15:37:59 -0700 Subject: [PATCH 3/3] fix: deduplicate cohost IDs, skip no-op PATCH on add --- src/commands/cohosts.js | 7 ++++++- src/lib/cohosts.js | 5 +++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/commands/cohosts.js b/src/commands/cohosts.js index 95b1c1e..d140eaf 100644 --- a/src/commands/cohosts.js +++ b/src/commands/cohosts.js @@ -75,6 +75,12 @@ export function registerCohostsCommands(program) { 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; @@ -82,7 +88,6 @@ export function registerCohostsCommands(program) { await setCohostIds(eventId, newIds, token, globalOpts.verbose); - const added = newIds.filter(id => !currentIds.includes(id)); 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); diff --git a/src/lib/cohosts.js b/src/lib/cohosts.js index ae22b50..aadc316 100644 --- a/src/lib/cohosts.js +++ b/src/lib/cohosts.js @@ -30,7 +30,7 @@ export async function resolveCohostNames(names, token, config, verbose = false) contacts.find(c => (c.name || '').toLowerCase() === q) || contacts.find(c => (c.name || '').toLowerCase().includes(q)); if (match?.userId) { - ids.push(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`); } @@ -52,9 +52,10 @@ export async function getCohostIds(eventId, token, verbose = false) { * 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: ids.map(id => ({ stringValue: id })) }, + arrayValue: { values: unique.map(id => ({ stringValue: id })) }, }, }; await firestoreRequest('PATCH', eventId, { fields }, token, ['cohostIds'], verbose);