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..d140eaf --- /dev/null +++ b/src/commands/cohosts.js @@ -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('', '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 }); + 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 }; + }); + + 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); + + 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('', '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); + + 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); + } + }); +} diff --git a/src/commands/events.js b/src/commands/events.js index 96b9907..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'; @@ -183,6 +184,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 +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; } @@ -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, }), @@ -409,6 +413,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 +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'); + } + } + 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 +568,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 +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, }), 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: { diff --git a/src/lib/cohosts.js b/src/lib/cohosts.js new file mode 100644 index 0000000..aadc316 --- /dev/null +++ b/src/lib/cohosts.js @@ -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); +}