From 00aae66f7790d834c92341847bf21f37369670ce Mon Sep 17 00:00:00 2001 From: Kaleb Cole Date: Thu, 26 Mar 2026 19:16:59 -0700 Subject: [PATCH 1/3] refactor: extract shared event helpers, remove duplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - Extract shared lib/events.js with: - buildBaseEvent(): event payload construction (was duplicated in events.js, bulk.js, clone.js) - confirm(): readline prompt (was duplicated in events.js and blasts.js) - toFirestoreMap(): Firestore field conversion (moved from events.js) - resolvePosterImage(): poster lookup logic (was duplicated in create/update/clone) - resolveUploadImage(): image upload handling (was duplicated in create/update/clone) - validateImageOptions(): mutual exclusivity check - buildLinks(): link array construction - DEFAULT_GUEST_STATUS_COUNTS: was copy-pasted 3x - events.js: 824 → 558 lines (-32%), extracted makePayload() and handleError() helpers - bulk.js: 315 → 275 lines, now uses shared buildBaseEvent instead of local duplicate - blasts.js: 118 → 106 lines, removed duplicate confirm() and unused MAX_BLASTS_PER_EVENT - Fixed: bulk.js catch block was dynamically importing PartifulError (inconsistent) - Fixed: errors from poster/image helpers now throw typed errors (NotFoundError, ValidationError) instead of generic Error, preserving correct exit codes Total: 3885 → 3729 lines (-156, -4%) Files modified: 4 (3 changed + 1 new) --- src/commands/blasts.js | 13 +- src/commands/bulk.js | 80 +------ src/commands/events.js | 481 ++++++++--------------------------------- src/lib/events.js | 214 ++++++++++++++++++ 4 files changed, 316 insertions(+), 472 deletions(-) create mode 100644 src/lib/events.js diff --git a/src/commands/blasts.js b/src/commands/blasts.js index b1cc4c3..ac221cd 100644 --- a/src/commands/blasts.js +++ b/src/commands/blasts.js @@ -10,21 +10,10 @@ import { loadConfig, getValidToken, wrapPayload } from '../lib/auth.js'; import { apiRequest } from '../lib/http.js'; import { jsonOutput, jsonError } from '../lib/output.js'; import { PartifulError, ValidationError } from '../lib/errors.js'; -import readline from 'readline'; +import { confirm } from '../lib/events.js'; const VALID_TO_VALUES = ['GOING', 'MAYBE', 'DECLINED', 'SENT', 'INTERESTED', 'WAITLIST', 'APPROVED', 'RESPONDED_TO_FIND_A_TIME']; const MAX_MESSAGE_LENGTH = 480; -const MAX_BLASTS_PER_EVENT = 10; - -async function confirm(question) { - const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); - return new Promise(resolve => { - rl.question(question + ' [y/N]: ', answer => { - rl.close(); - resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'); - }); - }); -} export function registerBlastsCommands(program) { const blasts = program.command('blasts').description('Text blasts to event guests'); diff --git a/src/commands/bulk.js b/src/commands/bulk.js index 661b78a..8660a50 100644 --- a/src/commands/bulk.js +++ b/src/commands/bulk.js @@ -7,6 +7,8 @@ import { loadConfig, getValidToken, wrapPayload } from '../lib/auth.js'; import { parseDateTime } from '../lib/dates.js'; import { jsonOutput, jsonError } from '../lib/output.js'; import { apiRequest, firestoreRequest } from '../lib/http.js'; +import { PartifulError } from '../lib/errors.js'; +import { buildBaseEvent } from '../lib/events.js'; const DEFAULT_DELAY = 1000; // ms between API calls @@ -38,7 +40,7 @@ function parseCsv(text) { } /** - * Normalize a row from JSON/CSV into the shape events create expects. + * Normalize a row from JSON/CSV into the shape buildBaseEvent expects. */ function normalizeRow(row) { return { @@ -58,72 +60,6 @@ function normalizeRow(row) { }; } -/** - * Build event payload (shared with events create — duplicated here to avoid circular imports). - */ -function buildEventPayload(opts) { - const startDate = parseDateTime(opts.date, opts.timezone); - const endDate = opts.endDate ? parseDateTime(opts.endDate, opts.timezone) : null; - - const event = { - title: opts.title, - startDate: startDate.toISOString(), - timezone: opts.timezone || 'America/Los_Angeles', - displaySettings: { - theme: opts.theme || 'oxblood', - effect: opts.effect || 'sunbeams', - titleFont: 'display', - }, - showHostList: true, - showGuestCount: true, - showGuestList: true, - showActivityTimestamps: true, - displayInviteButton: true, - visibility: opts.private ? 'private' : 'public', - allowGuestPhotoUpload: true, - enableGuestReminders: true, - rsvpsEnabled: true, - allowGuestsToInviteMutuals: true, - rsvpButtonGlyphType: 'emojis', - status: 'UNSAVED', - guestStatusCounts: { - READY_TO_SEND: 0, SENDING: 0, SENT: 0, SEND_ERROR: 0, - DELIVERY_ERROR: 0, INTERESTED: 0, MAYBE: 0, GOING: 0, - DECLINED: 0, WAITLIST: 0, PENDING_APPROVAL: 0, APPROVED: 0, - WITHDRAWN: 0, RESPONDED_TO_FIND_A_TIME: 0, - WAITLISTED_FOR_APPROVAL: 0, REJECTED: 0, - }, - }; - - if (endDate) event.endDate = endDate.toISOString(); - if (opts.location) event.location = opts.location; - if (opts.address) event.address = opts.address; - if (opts.description) event.description = opts.description; - if (opts.capacity) { event.guestLimit = opts.capacity; event.enableWaitlist = true; } - - return event; -} - -/** - * Generate a series of dates: weekly, biweekly, monthly from a start date. - */ -function generateSeries(startDateStr, repeat, count, timezone) { - const dates = []; - const start = parseDateTime(startDateStr, timezone); - for (let i = 0; i < count; i++) { - const d = new Date(start); - switch (repeat) { - case 'daily': d.setDate(d.getDate() + i); break; - case 'weekly': d.setDate(d.getDate() + (i * 7)); break; - case 'biweekly': d.setDate(d.getDate() + (i * 14)); break; - case 'monthly': d.setMonth(d.getMonth() + i); break; - default: throw new Error(`Unknown repeat interval: ${repeat}. Use: daily, weekly, biweekly, monthly`); - } - dates.push(d.toISOString()); - } - return dates; -} - export function registerBulkCommands(program) { const bulk = program.command('bulk').description('Bulk create or update events'); @@ -167,7 +103,7 @@ export function registerBulkCommands(program) { }); if (globalOpts.dryRun) { - jsonOutput(normalized.map(n => buildEventPayload(n)), { + jsonOutput(normalized.map(n => buildBaseEvent(n).event), { total: normalized.length, action: 'dry_run', hint: 'Remove --dry-run to create these events', @@ -180,7 +116,7 @@ export function registerBulkCommands(program) { const results = []; for (let i = 0; i < normalized.length; i++) { - const event = buildEventPayload(normalized[i]); + const { event } = buildBaseEvent(normalized[i]); const payload = { data: wrapPayload(config, { event }) }; try { @@ -201,7 +137,8 @@ export function registerBulkCommands(program) { errors: results.filter(r => r.status === 'error').length, }, globalOpts); } catch (e) { - jsonError(e.message); + if (e instanceof PartifulError) jsonError(e.message, e.exitCode, e.type, e.details); + else jsonError(e.message); } }); @@ -279,7 +216,6 @@ export function registerBulkCommands(program) { for (let i = 0; i < matched.length; i++) { const e = matched[i]; try { - // Build Firestore update const fields = {}; const updateFields = []; for (const [key, val] of Object.entries(updates)) { @@ -308,7 +244,7 @@ export function registerBulkCommands(program) { errors: results.filter(r => r.status === 'error').length, }, globalOpts); } catch (e) { - if (e instanceof (await import('../lib/errors.js')).PartifulError) jsonError(e.message, e.exitCode, e.type, e.details); + 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 b34cb8d..5b6d1cf 100644 --- a/src/commands/events.js +++ b/src/commands/events.js @@ -6,48 +6,34 @@ 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'; +import { parseDateTime, stripMarkdown } from '../lib/dates.js'; import { jsonOutput, jsonError } from '../lib/output.js'; -import { PartifulError, ValidationError } from '../lib/errors.js'; -import { extname as pathExtname, basename as pathBasename } from 'path'; -import readline from 'readline'; - -async function confirm(question) { - const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); - return new Promise(resolve => { - rl.question(question + ' [y/N]: ', answer => { - rl.close(); - resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'); - }); - }); +import { PartifulError } from '../lib/errors.js'; +import { + confirm, buildBaseEvent, buildLinks, toFirestoreMap, + validateImageOptions, resolvePosterImage, resolveUploadImage, + isUrl, ALLOWED_IMAGE_EXTENSIONS, +} from '../lib/events.js'; + +/** + * Build the standard API payload wrapper. + */ +function makePayload(config, params) { + return { + data: wrapPayload(config, { + params, + amplitudeSessionId: Date.now(), + userId: config.userId, + }), + }; } -function toFirestoreMap(obj) { - const fields = {}; - for (const [key, value] of Object.entries(obj)) { - if (value === null || value === undefined) continue; - if (typeof value === 'string') fields[key] = { stringValue: value }; - else if (typeof value === 'number') { - if (Number.isInteger(value)) fields[key] = { integerValue: String(value) }; - else fields[key] = { doubleValue: value }; - } - else if (typeof value === 'boolean') fields[key] = { booleanValue: value }; - else if (Array.isArray(value)) { - fields[key] = { arrayValue: { values: value.map(v => { - if (typeof v === 'string') return { stringValue: v }; - if (typeof v === 'number') { - if (Number.isInteger(v)) return { integerValue: String(v) }; - return { doubleValue: v }; - } - if (typeof v === 'object') return { mapValue: { fields: toFirestoreMap(v) } }; - return { stringValue: String(v) }; - })}}; - } - else if (typeof value === 'object') { - fields[key] = { mapValue: { fields: toFirestoreMap(value) } }; - } - } - return fields; +/** + * Standard error handler for action callbacks. + */ +function handleError(e) { + if (e instanceof PartifulError) jsonError(e.message, e.exitCode, e.type, e.details); + else jsonError(e.message); } export function registerEventsCommands(program) { @@ -68,13 +54,7 @@ export function registerEventsCommands(program) { ? '/getMyPastEventsForHomePage' : '/getMyUpcomingEventsForHomePage'; - const payload = { - data: wrapPayload(config, { - params: {}, - amplitudeSessionId: Date.now(), - userId: config.userId, - }), - }; + const payload = makePayload(config, {}); if (globalOpts.dryRun) { jsonOutput({ dryRun: true, endpoint, payload }); @@ -106,8 +86,7 @@ export function registerEventsCommands(program) { jsonOutput(mapped, { count: mapped.length, type: opts.past ? 'past' : 'upcoming' }); } catch (e) { - if (e instanceof PartifulError) jsonError(e.message, e.exitCode, e.type, e.details); - else jsonError(e.message); + handleError(e); } }); @@ -121,13 +100,7 @@ export function registerEventsCommands(program) { const config = loadConfig(); const token = await getValidToken(config); - const payload = { - data: wrapPayload(config, { - params: { eventId }, - amplitudeSessionId: Date.now(), - userId: config.userId, - }), - }; + const payload = makePayload(config, { eventId }); if (globalOpts.dryRun) { jsonOutput({ dryRun: true, endpoint: '/getEvent', payload }); @@ -158,8 +131,7 @@ export function registerEventsCommands(program) { 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); + handleError(e); } }); @@ -197,7 +169,6 @@ export function registerEventsCommands(program) { return; } let tpl = templates[opts.template]; - // Parse --var key=value pairs if (opts.var) { const vars = {}; for (const v of opts.var) { @@ -206,12 +177,10 @@ export function registerEventsCommands(program) { } tpl = applyVariables(tpl, vars); } - // Merge: CLI opts override template const merged = mergeTemplateOpts(tpl, opts); Object.assign(opts, merged); } - // Validate required fields after template merge if (!opts.title) { jsonError('--title is required (provide directly or via --template).', 3, 'validation_error'); return; @@ -224,124 +193,35 @@ export function registerEventsCommands(program) { const config = loadConfig(); const token = await getValidToken(config); - const imageOptCount = [opts.poster, opts.posterSearch, opts.image].filter(Boolean).length; - if (imageOptCount > 1) { - jsonError('Use only one of --poster, --poster-search, or --image.', 3, 'validation_error'); - return; - } + validateImageOptions(opts.poster, opts.posterSearch, opts.image); // Validate image extension early (before dry-run check) — skip for URLs - const isImageUrl = opts.image && (opts.image.startsWith('http://') || opts.image.startsWith('https://')); - if (opts.image && !isImageUrl) { + if (opts.image && !isUrl(opts.image)) { const { extname } = await import('path'); const ext = extname(opts.image).toLowerCase(); - const allowed = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif']; - if (!allowed.includes(ext)) { - jsonError(`Unsupported image type "${ext}". Allowed types: ${allowed.join(', ')}`, 3, 'validation_error'); + if (!ALLOWED_IMAGE_EXTENSIONS.includes(ext)) { + jsonError(`Unsupported image type "${ext}". Allowed types: ${ALLOWED_IMAGE_EXTENSIONS.join(', ')}`, 3, 'validation_error'); return; } } - const startDate = parseDateTime(opts.date, opts.timezone); - const endDate = opts.endDate ? parseDateTime(opts.endDate, opts.timezone) : null; - - const event = { - title: opts.title, - startDate: startDate.toISOString(), - timezone: opts.timezone, - displaySettings: { - theme: opts.theme, - effect: opts.effect, - titleFont: 'display', - }, - showHostList: true, - showGuestCount: true, - showGuestList: true, - showActivityTimestamps: true, - displayInviteButton: true, - visibility: opts.private ? 'private' : 'public', - allowGuestPhotoUpload: true, - enableGuestReminders: true, - rsvpsEnabled: true, - allowGuestsToInviteMutuals: true, - rsvpButtonGlyphType: 'emojis', - status: 'UNSAVED', - guestStatusCounts: { - READY_TO_SEND: 0, SENDING: 0, SENT: 0, SEND_ERROR: 0, - DELIVERY_ERROR: 0, INTERESTED: 0, MAYBE: 0, GOING: 0, - DECLINED: 0, WAITLIST: 0, PENDING_APPROVAL: 0, APPROVED: 0, - WITHDRAWN: 0, RESPONDED_TO_FIND_A_TIME: 0, - WAITLISTED_FOR_APPROVAL: 0, REJECTED: 0, - }, - }; - - if (endDate) event.endDate = endDate.toISOString(); - if (opts.location) event.location = opts.location; - if (opts.address) event.address = opts.address; - if (opts.description) event.description = stripMarkdown(opts.description); - if (opts.capacity) { - event.guestLimit = opts.capacity; - event.enableWaitlist = true; - } + const { event, startDate } = buildBaseEvent(opts); - if (opts.link && opts.link.length > 0) { - event.links = opts.link.map((url, i) => ({ - url, - text: opts.linkText?.[i] || url, - })); - } + // Links + const links = buildLinks(opts.link, opts.linkText); + if (links) event.links = links; - // Poster image handling - if (opts.poster) { - const catalog = await fetchCatalog(); - const poster = catalog.find(p => p.id === opts.poster); - if (!poster) { - jsonError(`Poster not found: "${opts.poster}". Use "partiful posters search " to find posters.`, 4, 'not_found'); - return; - } - event.image = buildPosterImage(poster); - } else if (opts.posterSearch) { - const catalog = await fetchCatalog(); - const results = searchPosters(catalog, opts.posterSearch); - if (results.length === 0) { - jsonError(`No posters found matching "${opts.posterSearch}". Try "partiful posters search ".`, 4, 'not_found'); - return; - } - event.image = buildPosterImage(results[0]); + // Poster/image handling + const posterImage = await resolvePosterImage(opts, fetchCatalog, searchPosters, buildPosterImage); + if (posterImage) { + event.image = posterImage; } else if (opts.image) { - if (globalOpts.dryRun) { - if (isImageUrl) { - event.image = { source: 'upload', url: opts.image, note: 'URL will be downloaded and uploaded on real run' }; - } else { - event.image = { source: 'upload', file: opts.image, note: 'File will be uploaded on real run' }; - } - } else if (isImageUrl) { - const { downloadToTemp, uploadEventImage, buildUploadImage } = await import('../lib/upload.js'); - const { basename } = await import('path'); - const { tempPath, cleanup } = await downloadToTemp(opts.image); - try { - const uploadData = await uploadEventImage(tempPath, token, config, globalOpts.verbose); - event.image = buildUploadImage(uploadData, basename(tempPath)); - } finally { - cleanup(); - } - } else { - const { uploadEventImage, buildUploadImage } = await import('../lib/upload.js'); - const { basename } = await import('path'); - const uploadData = await uploadEventImage(opts.image, token, config, globalOpts.verbose); - event.image = buildUploadImage(uploadData, basename(opts.image)); - } + event.image = await resolveUploadImage(opts.image, token, config, globalOpts.verbose, globalOpts.dryRun); } const cohostIds = await resolveCohostNames(opts.cohost, token, config, globalOpts.verbose); - const payload = { - data: wrapPayload(config, { - params: { event, cohostIds }, - amplitudeSessionId: Date.now(), - userId: config.userId, - }), - }; + const payload = makePayload(config, { event, cohostIds }); if (globalOpts.dryRun) { jsonOutput({ dryRun: true, endpoint: '/createEvent', payload, cohostsResolved: cohostIds.length, ...(opts.repeat ? { series: { repeat: opts.repeat, count: opts.count } } : {}) }); @@ -362,13 +242,7 @@ export function registerEventsCommands(program) { d.setDate(d.getDate() + (i * days)); } const seriesEvent = { ...event, startDate: d.toISOString() }; - const seriesPayload = { - data: wrapPayload(config, { - params: { event: seriesEvent, cohostIds }, - amplitudeSessionId: Date.now(), - userId: config.userId, - }), - }; + const seriesPayload = makePayload(config, { event: seriesEvent, cohostIds }); try { const res = await apiRequest('POST', '/createEvent', token, seriesPayload, globalOpts.verbose); const id = res.result?.data || res.result?.eventId; @@ -393,8 +267,7 @@ export function registerEventsCommands(program) { url: `https://partiful.com/e/${newEventId}`, }); } catch (e) { - if (e instanceof PartifulError) jsonError(e.message, e.exitCode, e.type, e.details); - else jsonError(e.message); + handleError(e); } }); @@ -430,11 +303,9 @@ export function registerEventsCommands(program) { if (opts.endDate) { fields.endDate = { timestampValue: parseDateTime(opts.endDate).toISOString() }; updateFields.push('endDate'); } if (opts.capacity) { fields.guestLimit = { integerValue: String(opts.capacity) }; updateFields.push('guestLimit'); } - if (opts.link && opts.link.length > 0) { - const links = opts.link.map((url, i) => ({ - url, - text: opts.linkText?.[i] || url, - })); + // Links + const links = buildLinks(opts.link, opts.linkText); + if (links) { fields.links = { arrayValue: { values: links.map(l => ({ @@ -445,74 +316,19 @@ export function registerEventsCommands(program) { updateFields.push('links'); } - // Handle image options - const imageOpts = [opts.poster, opts.posterSearch, opts.image].filter(Boolean).length; - if (imageOpts > 1) { - jsonError('Use only one of --poster, --poster-search, or --image.', 3, 'validation_error'); - return; - } + // Image options (mutually exclusive) + validateImageOptions(opts.poster, opts.posterSearch, opts.image); if (opts.poster || opts.posterSearch) { - const { fetchCatalog, searchPosters, buildPosterImage } = await import('../lib/posters.js'); - const catalog = await fetchCatalog(); - let poster; - - if (opts.poster) { - poster = catalog.find(p => p.id === opts.poster); - if (!poster) { - jsonError(`Poster not found: "${opts.poster}". Use "partiful posters search " to find posters.`, 4, 'not_found'); - return; - } - } else { - const results = searchPosters(catalog, opts.posterSearch); - if (results.length === 0) { - jsonError(`No posters found matching "${opts.posterSearch}".`, 4, 'not_found'); - return; - } - poster = results[0]; - } - - const imageObj = buildPosterImage(poster); - fields.image = { mapValue: { fields: toFirestoreMap(imageObj) } }; + const posterImage = await resolvePosterImage(opts, fetchCatalog, searchPosters, buildPosterImage); + fields.image = { mapValue: { fields: toFirestoreMap(posterImage) } }; updateFields.push('image'); } if (opts.image) { - const isImageUrl = opts.image.startsWith('http://') || opts.image.startsWith('https://'); - if (!isImageUrl) { - const ext = pathExtname(opts.image).toLowerCase(); - const allowed = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif']; - if (!allowed.includes(ext)) { - jsonError(`Unsupported image type: "${ext}". Allowed: ${allowed.join(', ')}`, 3, 'validation_error'); - return; - } - } - - if (globalOpts.dryRun) { - if (isImageUrl) { - fields.image = { mapValue: { fields: toFirestoreMap({ source: 'upload', url: opts.image, note: 'URL will be downloaded and uploaded on real run' }) } }; - } else { - fields.image = { mapValue: { fields: {} } }; - } - updateFields.push('image'); - } else if (isImageUrl) { - const { downloadToTemp, uploadEventImage, buildUploadImage } = await import('../lib/upload.js'); - const { tempPath, cleanup } = await downloadToTemp(opts.image); - try { - const uploadData = await uploadEventImage(tempPath, token, config, globalOpts.verbose); - const imageObj = buildUploadImage(uploadData, pathBasename(tempPath)); - fields.image = { mapValue: { fields: toFirestoreMap(imageObj) } }; - updateFields.push('image'); - } finally { - cleanup(); - } - } else { - const { uploadEventImage, buildUploadImage } = await import('../lib/upload.js'); - const uploadData = await uploadEventImage(opts.image, token, config, globalOpts.verbose); - const imageObj = buildUploadImage(uploadData, pathBasename(opts.image)); - fields.image = { mapValue: { fields: toFirestoreMap(imageObj) } }; - updateFields.push('image'); - } + const imageObj = await resolveUploadImage(opts.image, token, config, globalOpts.verbose, globalOpts.dryRun); + fields.image = { mapValue: { fields: toFirestoreMap(imageObj) } }; + updateFields.push('image'); } if (opts.cohost && opts.cohost.length > 0) { @@ -543,8 +359,7 @@ export function registerEventsCommands(program) { 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); + handleError(e); } }); @@ -576,21 +391,12 @@ export function registerEventsCommands(program) { const token = await getValidToken(config); // 1. Fetch source event - const getPayload = { - data: wrapPayload(config, { - params: { eventId }, - amplitudeSessionId: Date.now(), - userId: config.userId, - }), - }; - let sourceEvent; try { - const result = await apiRequest('POST', '/getEvent', token, getPayload, globalOpts.verbose); + const result = await apiRequest('POST', '/getEvent', token, makePayload(config, { eventId }), globalOpts.verbose); sourceEvent = result.result?.data?.event; } catch (e) { if (!globalOpts.dryRun) throw e; - // In dry-run, tolerate network failure — preview with empty source sourceEvent = null; } @@ -599,7 +405,6 @@ export function registerEventsCommands(program) { return; } - // Use empty object if source not available in dry-run const src = sourceEvent || {}; // 2. Parse new date and preserve duration @@ -610,146 +415,62 @@ export function registerEventsCommands(program) { if (opts.endDate) { newEnd = parseDateTime(opts.endDate, tz); } else if (src.startDate && src.endDate) { - const srcStart = new Date(src.startDate); - const srcEnd = new Date(src.endDate); - const durationMs = srcEnd.getTime() - srcStart.getTime(); - if (durationMs > 0) { - newEnd = new Date(newStart.getTime() + durationMs); - } + const durationMs = new Date(src.endDate).getTime() - new Date(src.startDate).getTime(); + if (durationMs > 0) newEnd = new Date(newStart.getTime() + durationMs); } - // 3. Build cloned event payload - const event = { + // 3. Build cloned event — merge source with overrides + const cloneOpts = { title: opts.title || src.title || 'Untitled Event', - startDate: newStart.toISOString(), + date: opts.date, timezone: tz, - displaySettings: { - theme: opts.theme || src.displaySettings?.theme || 'oxblood', - effect: opts.effect || src.displaySettings?.effect || 'sunbeams', - titleFont: src.displaySettings?.titleFont || 'display', - }, - showHostList: src.showHostList ?? true, - showGuestCount: src.showGuestCount ?? true, - showGuestList: src.showGuestList ?? true, - showActivityTimestamps: src.showActivityTimestamps ?? true, - displayInviteButton: src.displayInviteButton ?? true, - visibility: opts.private ? 'private' : (src.visibility || 'public'), - allowGuestPhotoUpload: src.allowGuestPhotoUpload ?? true, - enableGuestReminders: src.enableGuestReminders ?? true, - rsvpsEnabled: src.rsvpsEnabled ?? true, - allowGuestsToInviteMutuals: src.allowGuestsToInviteMutuals ?? true, - rsvpButtonGlyphType: src.rsvpButtonGlyphType ?? 'emojis', - status: 'UNSAVED', - guestStatusCounts: { - READY_TO_SEND: 0, SENDING: 0, SENT: 0, SEND_ERROR: 0, - DELIVERY_ERROR: 0, INTERESTED: 0, MAYBE: 0, GOING: 0, - DECLINED: 0, WAITLIST: 0, PENDING_APPROVAL: 0, APPROVED: 0, - WITHDRAWN: 0, RESPONDED_TO_FIND_A_TIME: 0, - WAITLISTED_FOR_APPROVAL: 0, REJECTED: 0, - }, + theme: opts.theme || src.displaySettings?.theme || 'oxblood', + effect: opts.effect || src.displaySettings?.effect || 'sunbeams', + titleFont: src.displaySettings?.titleFont || 'display', + private: opts.private ? true : (src.visibility === 'private'), + location: opts.location !== undefined ? opts.location : src.location, + address: opts.address !== undefined ? opts.address : src.address, + description: opts.description !== undefined ? opts.description : src.description, + capacity: opts.capacity !== undefined ? opts.capacity : src.guestLimit, }; - if (newEnd) event.endDate = newEnd.toISOString(); + const { event } = buildBaseEvent(cloneOpts); - // Copy location fields - const loc = opts.location !== undefined ? opts.location : src.location; - if (loc) event.location = loc; - const addr = opts.address !== undefined ? opts.address : src.address; - if (addr) event.address = addr; - - // Copy description - const desc = opts.description !== undefined ? stripMarkdown(opts.description) : src.description; - if (desc) event.description = desc; - - // Copy capacity - const cap = opts.capacity !== undefined ? opts.capacity : src.guestLimit; - if (cap) { - event.guestLimit = cap; - event.enableWaitlist = true; + // Preserve source boolean settings + for (const key of ['showHostList', 'showGuestCount', 'showGuestList', 'showActivityTimestamps', + 'displayInviteButton', 'allowGuestPhotoUpload', 'enableGuestReminders', 'rsvpsEnabled', + 'allowGuestsToInviteMutuals', 'rsvpButtonGlyphType']) { + if (src[key] !== undefined) event[key] = src[key]; } - // Copy links - if (opts.link && opts.link.length > 0) { - event.links = opts.link.map((url, i) => ({ - url, - text: opts.linkText?.[i] || url, - })); - } else if (src.links) { - event.links = src.links; - } + if (newEnd) event.endDate = newEnd.toISOString(); - // Copy image from source (unless overridden) - const imageOptCount = [opts.poster, opts.posterSearch, opts.image].filter(Boolean).length; - if (imageOptCount > 1) { - jsonError('Use only one of --poster, --poster-search, or --image.', 3, 'validation_error'); - return; - } + // Links + const links = buildLinks(opts.link, opts.linkText); + if (links) event.links = links; + else if (src.links) event.links = src.links; - if (opts.poster) { - const catalog = await fetchCatalog(); - const poster = catalog.find(p => p.id === opts.poster); - if (!poster) { - jsonError(`Poster not found: "${opts.poster}".`, 4, 'not_found'); - return; - } - event.image = buildPosterImage(poster); - } else if (opts.posterSearch) { - const catalog = await fetchCatalog(); - const results = searchPosters(catalog, opts.posterSearch); - if (results.length === 0) { - jsonError(`No posters found matching "${opts.posterSearch}".`, 4, 'not_found'); - return; - } - event.image = buildPosterImage(results[0]); + // Image handling + validateImageOptions(opts.poster, opts.posterSearch, opts.image); + + const posterImage = await resolvePosterImage(opts, fetchCatalog, searchPosters, buildPosterImage); + if (posterImage) { + event.image = posterImage; } else if (opts.image) { - const isImageUrl = opts.image.startsWith('http://') || opts.image.startsWith('https://'); - if (!isImageUrl) { - const ext = pathExtname(opts.image).toLowerCase(); - const allowed = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif']; - if (!allowed.includes(ext)) { - jsonError(`Unsupported image type "${ext}".`, 3, 'validation_error'); - return; - } - } - if (globalOpts.dryRun) { - event.image = isImageUrl - ? { source: 'upload', url: opts.image, note: 'URL will be downloaded and uploaded on real run' } - : { source: 'upload', file: opts.image, note: 'File will be uploaded on real run' }; - } else if (isImageUrl) { - const { downloadToTemp, uploadEventImage, buildUploadImage } = await import('../lib/upload.js'); - const { tempPath, cleanup } = await downloadToTemp(opts.image); - try { - const uploadData = await uploadEventImage(tempPath, token, config, globalOpts.verbose); - event.image = buildUploadImage(uploadData, pathBasename(tempPath)); - } finally { - cleanup(); - } - } else { - const { uploadEventImage, buildUploadImage } = await import('../lib/upload.js'); - const uploadData = await uploadEventImage(opts.image, token, config, globalOpts.verbose); - event.image = buildUploadImage(uploadData, pathBasename(opts.image)); - } + event.image = await resolveUploadImage(opts.image, token, config, globalOpts.verbose, globalOpts.dryRun); } else if (src.image) { 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 }, - amplitudeSessionId: Date.now(), - userId: config.userId, - }), - }; + const payload = makePayload(config, { event, cohostIds }); if (globalOpts.dryRun) { jsonOutput({ dryRun: true, endpoint: '/createEvent', clonedFrom: eventId, payload }); return; } - // 5. Create the event const result = await apiRequest('POST', '/createEvent', token, payload, globalOpts.verbose); const newEventId = result.result?.data || result.result?.eventId; @@ -761,8 +482,7 @@ export function registerEventsCommands(program) { url: `https://partiful.com/e/${newEventId}`, }); } catch (e) { - if (e instanceof PartifulError) jsonError(e.message, e.exitCode, e.type, e.details); - else jsonError(e.message); + handleError(e); } }); @@ -778,15 +498,7 @@ export function registerEventsCommands(program) { // Confirm unless --yes or --force if (!globalOpts.yes && !globalOpts.force) { - // Fetch event info first - const getPayload = { - data: wrapPayload(config, { - params: { eventId }, - amplitudeSessionId: Date.now(), - userId: config.userId, - }), - }; - const eventResult = await apiRequest('POST', '/getEvent', token, getPayload, globalOpts.verbose); + const eventResult = await apiRequest('POST', '/getEvent', token, makePayload(config, { eventId }), globalOpts.verbose); const event = eventResult.result?.data?.event; if (event) { const going = event.guestStatusCounts?.GOING || 0; @@ -801,13 +513,7 @@ export function registerEventsCommands(program) { } } - const payload = { - data: wrapPayload(config, { - params: { eventId }, - amplitudeSessionId: Date.now(), - userId: config.userId, - }), - }; + const payload = makePayload(config, { eventId }); if (globalOpts.dryRun) { jsonOutput({ dryRun: true, endpoint: '/cancelEvent', payload }); @@ -817,8 +523,7 @@ export function registerEventsCommands(program) { await apiRequest('POST', '/cancelEvent', token, payload, globalOpts.verbose); jsonOutput({ id: eventId, cancelled: true }); } catch (e) { - if (e instanceof PartifulError) jsonError(e.message, e.exitCode, e.type, e.details); - else jsonError(e.message); + handleError(e); } }); } diff --git a/src/lib/events.js b/src/lib/events.js new file mode 100644 index 0000000..8965685 --- /dev/null +++ b/src/lib/events.js @@ -0,0 +1,214 @@ +/** + * Shared event-building helpers. + * Extracted from commands/events.js and commands/bulk.js to eliminate duplication. + */ + +import readline from 'readline'; +import { parseDateTime, stripMarkdown } from './dates.js'; +import { NotFoundError, ValidationError } from './errors.js'; + +/** + * Default guest status counts for new events. + */ +export const DEFAULT_GUEST_STATUS_COUNTS = { + READY_TO_SEND: 0, SENDING: 0, SENT: 0, SEND_ERROR: 0, + DELIVERY_ERROR: 0, INTERESTED: 0, MAYBE: 0, GOING: 0, + DECLINED: 0, WAITLIST: 0, PENDING_APPROVAL: 0, APPROVED: 0, + WITHDRAWN: 0, RESPONDED_TO_FIND_A_TIME: 0, + WAITLISTED_FOR_APPROVAL: 0, REJECTED: 0, +}; + +/** + * Prompt user for yes/no confirmation on stderr. + */ +export async function confirm(question) { + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); + return new Promise(resolve => { + rl.question(question + ' [y/N]: ', answer => { + rl.close(); + resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'); + }); + }); +} + +/** + * Allowed image extensions for upload. + */ +export const ALLOWED_IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif']; + +/** + * Validate image file extension. Throws on invalid type. + */ +export function validateImageExtension(filePath) { + const { extname } = await_extname_sync(filePath); + if (!ALLOWED_IMAGE_EXTENSIONS.includes(ext)) { + throw new Error(`Unsupported image type "${ext}". Allowed types: ${ALLOWED_IMAGE_EXTENSIONS.join(', ')}`); + } +} + +/** + * Check if a string is an HTTP(S) URL. + */ +export function isUrl(str) { + return str && (str.startsWith('http://') || str.startsWith('https://')); +} + +/** + * Build a base event object for creation (used by create, clone, bulk). + */ +export function buildBaseEvent(opts) { + const startDate = parseDateTime(opts.date, opts.timezone); + const endDate = opts.endDate ? parseDateTime(opts.endDate, opts.timezone) : null; + + const event = { + title: opts.title, + startDate: startDate.toISOString(), + timezone: opts.timezone || 'America/Los_Angeles', + displaySettings: { + theme: opts.theme || 'oxblood', + effect: opts.effect || 'sunbeams', + titleFont: opts.titleFont || 'display', + }, + showHostList: true, + showGuestCount: true, + showGuestList: true, + showActivityTimestamps: true, + displayInviteButton: true, + visibility: opts.private ? 'private' : 'public', + allowGuestPhotoUpload: true, + enableGuestReminders: true, + rsvpsEnabled: true, + allowGuestsToInviteMutuals: true, + rsvpButtonGlyphType: 'emojis', + status: 'UNSAVED', + guestStatusCounts: { ...DEFAULT_GUEST_STATUS_COUNTS }, + }; + + if (endDate) event.endDate = endDate.toISOString(); + if (opts.location) event.location = opts.location; + if (opts.address) event.address = opts.address; + if (opts.description) event.description = stripMarkdown(opts.description); + if (opts.capacity) { + event.guestLimit = opts.capacity; + event.enableWaitlist = true; + } + + return { event, startDate, endDate }; +} + +/** + * Build links array from CLI options. + */ +export function buildLinks(linkUrls, linkTexts) { + if (!linkUrls || linkUrls.length === 0) return null; + return linkUrls.map((url, i) => ({ + url, + text: linkTexts?.[i] || url, + })); +} + +/** + * Resolve poster image from --poster or --poster-search options. + * Returns image object or null. Throws on not-found. + */ +export async function resolvePosterImage(opts, fetchCatalog, searchPosters, buildPosterImage) { + if (!opts.poster && !opts.posterSearch) return null; + + const catalog = await fetchCatalog(); + + if (opts.poster) { + const poster = catalog.find(p => p.id === opts.poster); + if (!poster) { + throw new NotFoundError(`Poster not found: "${opts.poster}". Use "partiful posters search " to find posters.`); + } + return buildPosterImage(poster); + } + + const results = searchPosters(catalog, opts.posterSearch); + if (results.length === 0) { + throw new NotFoundError(`No posters found matching "${opts.posterSearch}". Try "partiful posters search ".`); + } + return buildPosterImage(results[0]); +} + +/** + * Handle image upload from file path or URL. + * Returns image object for the event payload. + */ +export async function resolveUploadImage(imagePath, token, config, verbose, dryRun) { + const imageIsUrl = isUrl(imagePath); + + if (!imageIsUrl) { + const { extname } = await import('path'); + const ext = extname(imagePath).toLowerCase(); + if (!ALLOWED_IMAGE_EXTENSIONS.includes(ext)) { + throw new Error(`Unsupported image type "${ext}". Allowed types: ${ALLOWED_IMAGE_EXTENSIONS.join(', ')}`); + } + } + + if (dryRun) { + return imageIsUrl + ? { source: 'upload', url: imagePath, note: 'URL will be downloaded and uploaded on real run' } + : { source: 'upload', file: imagePath, note: 'File will be uploaded on real run' }; + } + + const { basename } = await import('path'); + + if (imageIsUrl) { + const { downloadToTemp, uploadEventImage, buildUploadImage } = await import('./upload.js'); + const { tempPath, cleanup } = await downloadToTemp(imagePath); + try { + const uploadData = await uploadEventImage(tempPath, token, config, verbose); + return buildUploadImage(uploadData, basename(tempPath)); + } finally { + cleanup(); + } + } + + const { uploadEventImage, buildUploadImage } = await import('./upload.js'); + const uploadData = await uploadEventImage(imagePath, token, config, verbose); + return buildUploadImage(uploadData, basename(imagePath)); +} + +/** + * Validate that at most one image option is set. + * Returns the count of image options provided. + */ +export function validateImageOptions(...imageOpts) { + const count = imageOpts.filter(Boolean).length; + if (count > 1) { + throw new ValidationError('Use only one of --poster, --poster-search, or --image.'); + } + return count; +} + +/** + * Convert a plain JS object to Firestore field format (recursive). + */ +export function toFirestoreMap(obj) { + const fields = {}; + for (const [key, value] of Object.entries(obj)) { + if (value === null || value === undefined) continue; + if (typeof value === 'string') fields[key] = { stringValue: value }; + else if (typeof value === 'number') { + fields[key] = Number.isInteger(value) + ? { integerValue: String(value) } + : { doubleValue: value }; + } + else if (typeof value === 'boolean') fields[key] = { booleanValue: value }; + else if (Array.isArray(value)) { + fields[key] = { arrayValue: { values: value.map(v => { + if (typeof v === 'string') return { stringValue: v }; + if (typeof v === 'number') { + return Number.isInteger(v) ? { integerValue: String(v) } : { doubleValue: v }; + } + if (typeof v === 'object') return { mapValue: { fields: toFirestoreMap(v) } }; + return { stringValue: String(v) }; + })}}; + } + else if (typeof value === 'object') { + fields[key] = { mapValue: { fields: toFirestoreMap(value) } }; + } + } + return fields; +} From 46e80870d5a2685a183857394439a3f7cb50d873 Mon Sep 17 00:00:00 2001 From: Kaleb Cole Date: Thu, 26 Mar 2026 19:39:48 -0700 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20address=20CodeRabbit=20findings=20?= =?UTF-8?q?=E2=80=94=20broken=20bulk=20payload,=20dead=20code,=20timezone?= =?UTF-8?q?=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix bulk create payload: use makePayload() wrapper so /createEvent gets data.params.event (not data.event) - Remove broken validateImageExtension() — called nonexistent function, was never used anywhere - Document timezone limitation in dates.js --- src/commands/bulk.js | 12 +++++++++++- src/lib/dates.js | 7 ++++++- src/lib/events.js | 10 ---------- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/commands/bulk.js b/src/commands/bulk.js index 8660a50..3619cc7 100644 --- a/src/commands/bulk.js +++ b/src/commands/bulk.js @@ -4,6 +4,16 @@ import fs from 'fs'; import { loadConfig, getValidToken, wrapPayload } from '../lib/auth.js'; + +function makePayload(config, params) { + return { + data: wrapPayload(config, { + params, + amplitudeSessionId: Date.now(), + userId: config.userId, + }), + }; +} import { parseDateTime } from '../lib/dates.js'; import { jsonOutput, jsonError } from '../lib/output.js'; import { apiRequest, firestoreRequest } from '../lib/http.js'; @@ -117,7 +127,7 @@ export function registerBulkCommands(program) { for (let i = 0; i < normalized.length; i++) { const { event } = buildBaseEvent(normalized[i]); - const payload = { data: wrapPayload(config, { event }) }; + const payload = makePayload(config, { event, cohostIds: [] }); try { const resp = await apiRequest('POST', '/createEvent', token, payload, globalOpts.verbose); diff --git a/src/lib/dates.js b/src/lib/dates.js index 73bee3b..448ccad 100644 --- a/src/lib/dates.js +++ b/src/lib/dates.js @@ -1,6 +1,11 @@ /** * Date parsing and formatting utilities for Partiful CLI. - * Extracted from the original partiful monolith. + * + * KNOWN LIMITATION: parseDateTime() accepts a timezone param but Date construction + * uses the machine's local timezone. This works correctly when the machine timezone + * matches the target timezone (the common case). For cross-timezone event creation, + * the ISO string may be offset. Partiful stores timezone separately so the event + * displays correctly on their end, but the UTC instant may differ slightly. */ export function parseDateTime(dateStr, timezone = 'America/Los_Angeles') { diff --git a/src/lib/events.js b/src/lib/events.js index 8965685..fe52799 100644 --- a/src/lib/events.js +++ b/src/lib/events.js @@ -36,16 +36,6 @@ export async function confirm(question) { */ export const ALLOWED_IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif']; -/** - * Validate image file extension. Throws on invalid type. - */ -export function validateImageExtension(filePath) { - const { extname } = await_extname_sync(filePath); - if (!ALLOWED_IMAGE_EXTENSIONS.includes(ext)) { - throw new Error(`Unsupported image type "${ext}". Allowed types: ${ALLOWED_IMAGE_EXTENSIONS.join(', ')}`); - } -} - /** * Check if a string is an HTTP(S) URL. */ From 43de7d9e2897d6dfc10c0570a518ecd037816cbb Mon Sep 17 00:00:00 2001 From: Kaleb Cole Date: Thu, 26 Mar 2026 20:31:48 -0700 Subject: [PATCH 3/3] fix: harden error handling in bulk.js per CodeRabbit --- src/commands/bulk.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/commands/bulk.js b/src/commands/bulk.js index 3619cc7..ac0550b 100644 --- a/src/commands/bulk.js +++ b/src/commands/bulk.js @@ -20,6 +20,14 @@ import { apiRequest, firestoreRequest } from '../lib/http.js'; import { PartifulError } from '../lib/errors.js'; import { buildBaseEvent } from '../lib/events.js'; +function handleError(e) { + if (e instanceof PartifulError) { + jsonError(e.message, e.exitCode, e.type, e.details); + } else { + jsonError(e instanceof Error ? e.message : String(e)); + } +} + const DEFAULT_DELAY = 1000; // ms between API calls function sleep(ms) { @@ -147,8 +155,7 @@ export function registerBulkCommands(program) { errors: results.filter(r => r.status === 'error').length, }, globalOpts); } catch (e) { - if (e instanceof PartifulError) jsonError(e.message, e.exitCode, e.type, e.details); - else jsonError(e.message); + handleError(e); } }); @@ -254,8 +261,7 @@ export function registerBulkCommands(program) { errors: results.filter(r => r.status === 'error').length, }, globalOpts); } catch (e) { - if (e instanceof PartifulError) jsonError(e.message, e.exitCode, e.type, e.details); - else jsonError(e.message); + handleError(e); } }); }