diff --git a/docs/plans/2026-03-24-event-images-implementation.md b/docs/plans/2026-03-24-event-images-implementation.md new file mode 100644 index 0000000..6b41689 --- /dev/null +++ b/docs/plans/2026-03-24-event-images-implementation.md @@ -0,0 +1,1033 @@ +# Event Images CLI — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add poster browsing (`posters list/search`) and image support (`--poster`, `--image`) to `events create` and `events update`, closing issue #5. + +**Architecture:** New `posters.js` command module fetches the public poster catalog (`https://assets.getpartiful.com/posters.json`). Event commands get `--poster ` and `--image ` flags. Poster selection builds the `event.image` object with `source: "partiful_posters"`. Custom upload uses `POST /uploadPhoto` with `uploadType: "event_poster"` via multipart FormData. Update uses `POST /updateEvent` (callable function) instead of Firestore PATCH for image fields since the image object is complex/nested. + +**Tech Stack:** Node.js, Commander, vitest, native fetch + FormData + +**Design Spec:** `docs/research/2026-03-24-event-image-schema.md` + +**Closes:** #5 + +--- + +## API Research Summary + +### Image Source Types (from app bundle module 90126) + +```text +GIPHY = "giphy" — GIF search (Giphy API, skip for v1) +LOCAL = "local" — client-only, not persisted +UNSPLASH = "unsplash" — stock photos (skip for v1) +UPLOAD = "upload" — custom image upload +PARTIFUL_POSTERS = "partiful_posters" — built-in poster library +``` + +### Poster Catalog + +- **URL:** `https://assets.getpartiful.com/posters.json` (public, no auth) +- **Count:** ~1,979 posters +- **Content types:** PNG, JPEG, AVIF, GIF (1 GIF) +- **Categories:** Trending, Birthday, Elegant, Minimal, Dinner Party, Themed, Community Made, Chill, Not Chill, Holiday, College, Watch Party, Wedding, Outdoors, etc. + +### Image Object in createEvent Payload + +**Poster:** +```json +{ + "image": { + "source": "partiful_posters", + "poster": { "id": "...", "name": "...", "contentType": "...", "url": "...", "width": 1600, "height": 1600, ... }, + "url": "https://assets.getpartiful.com/posters/", + "blurHash": "...", + "contentType": "image/png", + "name": "", + "height": 1600, + "width": 1600 + } +} +``` + +**Custom Upload:** +```json +{ + "image": { + "source": "upload", + "type": "image", + "upload": { "path": "", "url": "", "contentType": "...", "size": 12345, "width": 800, "height": 600 }, + "url": "", + "contentType": "...", + "name": "", + "height": 600, + "width": 800 + } +} +``` + +### Upload Endpoint + +```text +POST https://api.partiful.com/uploadPhoto +Content-Type: multipart/form-data + +FormData: + - file: + - (params are URL-encoded in the callable function URL) + +Callable function pattern: POST to `${baseUrl}/uploadPhoto` with params: { uploadType: "event_poster" } +Returns: { uploadData: { path, url, contentType, size, width, height } } +``` + +--- + +## Phase 1: Poster Browsing (`posters list`, `posters search`) + +### Task 1.1: Create `src/commands/posters.js` — list command + +**Files:** +- Create: `src/commands/posters.js` +- Modify: `src/cli.js` (register new command) + +**Step 1: Write the failing test** + +Create `tests/posters-integration.test.js`: + +```javascript +import { describe, it, expect } from 'vitest'; +import { run } from './helpers.js'; + +describe('posters integration', () => { + describe('posters list', () => { + it('lists posters with default limit', () => { + const out = run(['posters', 'list']); + expect(out.status).toBe('success'); + expect(out.data).toBeInstanceOf(Array); + expect(out.data.length).toBeGreaterThan(0); + expect(out.data.length).toBeLessThanOrEqual(20); + expect(out.data[0]).toHaveProperty('id'); + expect(out.data[0]).toHaveProperty('url'); + expect(out.data[0]).toHaveProperty('categories'); + }); + + it('respects --limit', () => { + const out = run(['posters', 'list', '--limit', '5']); + expect(out.data.length).toBeLessThanOrEqual(5); + }); + + it('filters by --category', () => { + const out = run(['posters', 'list', '--category', 'Birthday']); + expect(out.data.length).toBeGreaterThan(0); + out.data.forEach(p => { + expect(p.categories).toContain('Birthday'); + }); + }); + + it('returns metadata count', () => { + const out = run(['posters', 'list', '--limit', '3']); + expect(out.metadata.count).toBeDefined(); + expect(out.metadata.totalAvailable).toBeDefined(); + }); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +```bash +npx vitest run tests/posters-integration.test.js +``` + +Expected: FAIL — `posters` command not found. + +**Step 3: Implement `src/commands/posters.js`** + +```javascript +/** + * Posters commands: list, search + * Browses Partiful's public poster catalog. + */ + +import { jsonOutput, jsonError } from '../lib/output.js'; + +const CATALOG_URL = 'https://assets.getpartiful.com/posters.json'; +let _cache = null; + +async function fetchCatalog() { + if (_cache) return _cache; + const res = await fetch(CATALOG_URL); + if (!res.ok) throw new Error(`Failed to fetch poster catalog: ${res.status}`); + _cache = await res.json(); + return _cache; +} + +export function registerPosterCommands(program) { + const posters = program.command('posters').description('Browse Partiful poster library'); + + posters + .command('list') + .description('List available posters') + .option('--category ', 'Filter by category (e.g. Birthday, Trending)') + .option('--type ', 'Filter by content type (png, gif, jpeg)', '') + .option('--limit ', 'Max results', parseInt, 20) + .action(async (opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + try { + let catalog = await fetchCatalog(); + + if (opts.category) { + const cat = opts.category.toLowerCase(); + catalog = catalog.filter(p => + p.categories.some(c => c.toLowerCase() === cat) + ); + } + + if (opts.type) { + const t = opts.type.toLowerCase(); + catalog = catalog.filter(p => + p.contentType.includes(t) + ); + } + + const totalAvailable = catalog.length; + const limited = catalog.slice(0, opts.limit); + + const data = limited.map(p => ({ + id: p.id, + name: p.name, + contentType: p.contentType, + categories: p.categories, + tags: p.tags, + width: p.width, + height: p.height, + url: p.url, + thumbnail: `https://partiful-posters.imgix.net/${encodeURIComponent(p.id)}?fit=max&w=400`, + bgColor: p.bgColor, + })); + + jsonOutput(data, { count: data.length, totalAvailable }); + } catch (e) { + jsonError(e.message); + } + }); + + posters + .command('search') + .description('Search posters by keyword') + .argument('', 'Search query (matches tags, name, categories)') + .option('--limit ', 'Max results', parseInt, 10) + .action(async (query, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + try { + const catalog = await fetchCatalog(); + const q = query.toLowerCase(); + + const scored = catalog.map(p => { + let score = 0; + // Tag exact match = highest + if (p.tags.some(t => t.toLowerCase() === q)) score += 10; + // Tag partial match + if (p.tags.some(t => t.toLowerCase().includes(q))) score += 5; + // Name match + if (p.name.toLowerCase().includes(q)) score += 3; + // Category match + if (p.categories.some(c => c.toLowerCase().includes(q))) score += 2; + return { poster: p, score }; + }) + .filter(s => s.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, opts.limit); + + const data = scored.map(s => ({ + id: s.poster.id, + name: s.poster.name, + contentType: s.poster.contentType, + categories: s.poster.categories, + tags: s.poster.tags, + url: s.poster.url, + thumbnail: `https://partiful-posters.imgix.net/${encodeURIComponent(s.poster.id)}?fit=max&w=400`, + score: s.score, + })); + + jsonOutput(data, { count: data.length, query }); + } catch (e) { + jsonError(e.message); + } + }); + + posters + .command('get') + .description('Get full poster details by ID') + .argument('', 'Poster ID') + .action(async (posterId, opts, cmd) => { + try { + const catalog = await fetchCatalog(); + const poster = catalog.find(p => p.id === posterId); + if (!poster) { + jsonError(`Poster not found: ${posterId}`, 4, 'not_found'); + return; + } + jsonOutput(poster); + } catch (e) { + jsonError(e.message); + } + }); +} +``` + +**Step 4: Register in `src/cli.js`** + +Add import and registration: + +```javascript +import { registerPosterCommands } from './commands/posters.js'; +// ... in run(): +registerPosterCommands(program); +``` + +**Step 5: Run tests to verify they pass** + +```bash +npx vitest run tests/posters-integration.test.js +``` + +Expected: PASS (all tests). Note: these tests make a real HTTP call to the public catalog URL. If that's a concern for CI, we can mock later. + +**Step 6: Commit** + +```bash +git add src/commands/posters.js src/cli.js tests/posters-integration.test.js +git commit -m "feat(#5): add posters list/search/get commands" +``` + +--- + +### Task 1.2: Add search tests + +**Files:** +- Modify: `tests/posters-integration.test.js` + +**Step 1: Add search tests to existing file** + +```javascript + describe('posters search', () => { + it('finds posters by tag', () => { + const out = run(['posters', 'search', 'birthday']); + expect(out.status).toBe('success'); + expect(out.data.length).toBeGreaterThan(0); + expect(out.data[0]).toHaveProperty('score'); + }); + + it('respects --limit', () => { + const out = run(['posters', 'search', 'party', '--limit', '3']); + expect(out.data.length).toBeLessThanOrEqual(3); + }); + + it('returns empty for nonsense query', () => { + const out = run(['posters', 'search', 'xyzzyflurble123']); + expect(out.data).toEqual([]); + expect(out.metadata.count).toBe(0); + }); + }); + + describe('posters get', () => { + it('returns poster by exact ID', () => { + const out = run(['posters', 'get', 'piscesairbrush.png']); + expect(out.status).toBe('success'); + expect(out.data.id).toBe('piscesairbrush.png'); + expect(out.data.url).toContain('assets.getpartiful.com'); + }); + + it('errors on unknown poster', () => { + const out = run(['posters', 'get', 'does-not-exist-xyz']); + expect(out.status).toBe('error'); + expect(out.error.type).toBe('not_found'); + }); + }); +``` + +**Step 2: Run tests** + +```bash +npx vitest run tests/posters-integration.test.js +``` + +Expected: PASS (all). + +**Step 3: Commit** + +```bash +git add tests/posters-integration.test.js +git commit -m "test(#5): add search and get poster tests" +``` + +--- + +## Phase 2: Poster Support in `events create` + +### Task 2.1: Add `--poster` flag to `events create` + +**Files:** +- Modify: `src/commands/events.js` +- Modify: `tests/events-integration.test.js` + +**Step 1: Write the failing test** + +Add to `tests/events-integration.test.js`: + +```javascript + it('events create --poster includes image in payload', () => { + const out = run([ + 'events', 'create', + '--title', 'Poster Test', + '--date', '2026-06-01 7pm', + '--poster', 'piscesairbrush.png', + '--dry-run', + ]); + expect(out.status).toBe('success'); + const event = out.data.payload.data.params.event; + expect(event.image).toBeDefined(); + expect(event.image.source).toBe('partiful_posters'); + expect(event.image.poster.id).toBe('piscesairbrush.png'); + expect(event.image.url).toContain('assets.getpartiful.com'); + }); + + it('events create --poster errors on unknown poster', () => { + const out = run([ + 'events', 'create', + '--title', 'Bad Poster', + '--date', '2026-06-01 7pm', + '--poster', 'nonexistent-poster-xyz', + '--dry-run', + ]); + expect(out.status).toBe('error'); + expect(out.error.type).toBe('not_found'); + }); +``` + +**Step 2: Run test to verify it fails** + +```bash +npx vitest run tests/events-integration.test.js -t "poster" +``` + +Expected: FAIL — `--poster` option not recognized. + +**Step 3: Implement `--poster` in events create** + +In `src/commands/events.js`, add to the `create` command: + +1. Add option: `.option('--poster ', 'Built-in poster ID (use "posters search" to find)')` +2. After building the `event` object, before building `payload`: + +```javascript + // Handle poster image + if (opts.poster) { + const posterCatalogUrl = 'https://assets.getpartiful.com/posters.json'; + const catalogRes = await fetch(posterCatalogUrl); + if (!catalogRes.ok) throw new PartifulError('Failed to fetch poster catalog', 1, 'fetch_error'); + const catalog = await catalogRes.json(); + 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 = { + source: 'partiful_posters', + poster, + url: poster.url, + blurHash: poster.blurHash, + contentType: poster.contentType, + name: poster.name, + height: poster.height, + width: poster.width, + }; + } +``` + +**Step 4: Run tests** + +```bash +npx vitest run tests/events-integration.test.js +``` + +Expected: PASS. + +**Step 5: Commit** + +```bash +git add src/commands/events.js tests/events-integration.test.js +git commit -m "feat(#5): add --poster flag to events create" +``` + +--- + +### Task 2.2: Add `--poster-search` convenience flag + +**Files:** +- Modify: `src/commands/events.js` +- Modify: `tests/events-integration.test.js` + +**Step 1: Write failing test** + +```javascript + it('events create --poster-search finds and uses best match', () => { + const out = run([ + 'events', 'create', + '--title', 'Search Test', + '--date', '2026-06-01 7pm', + '--poster-search', 'birthday', + '--dry-run', + ]); + expect(out.status).toBe('success'); + const event = out.data.payload.data.params.event; + expect(event.image).toBeDefined(); + expect(event.image.source).toBe('partiful_posters'); + }); + + it('events create errors when both --poster and --poster-search given', () => { + const out = run([ + 'events', 'create', + '--title', 'Conflict', + '--date', '2026-06-01 7pm', + '--poster', 'piscesairbrush.png', + '--poster-search', 'birthday', + '--dry-run', + ]); + expect(out.status).toBe('error'); + }); +``` + +**Step 2: Implement** + +Add option: `.option('--poster-search ', 'Search poster library and use best match')` + +Add validation + logic: + +```javascript + if (opts.poster && opts.posterSearch) { + jsonError('Cannot use both --poster and --poster-search. Pick one.', 3, 'validation_error'); + return; + } + + if (opts.posterSearch) { + const catalogRes = await fetch('https://assets.getpartiful.com/posters.json'); + if (!catalogRes.ok) throw new PartifulError('Failed to fetch poster catalog', 1, 'fetch_error'); + const catalog = await catalogRes.json(); + const q = opts.posterSearch.toLowerCase(); + const match = catalog + .map(p => { + let score = 0; + if (p.tags.some(t => t.toLowerCase() === q)) score += 10; + if (p.tags.some(t => t.toLowerCase().includes(q))) score += 5; + if (p.name.toLowerCase().includes(q)) score += 3; + if (p.categories.some(c => c.toLowerCase().includes(q))) score += 2; + return { poster: p, score }; + }) + .filter(s => s.score > 0) + .sort((a, b) => b.score - a.score)[0]; + + if (!match) { + jsonError(`No posters found matching "${opts.posterSearch}". Try "partiful posters search ".`, 4, 'not_found'); + return; + } + + const poster = match.poster; + event.image = { + source: 'partiful_posters', + poster, + url: poster.url, + blurHash: poster.blurHash, + contentType: poster.contentType, + name: poster.name, + height: poster.height, + width: poster.width, + }; + } +``` + +**Step 3: Run tests, commit** + +```bash +npx vitest run tests/events-integration.test.js +git add src/commands/events.js tests/events-integration.test.js +git commit -m "feat(#5): add --poster-search flag to events create" +``` + +--- + +### Task 2.3: Extract shared poster helpers to `src/lib/posters.js` + +The poster catalog fetch and search logic is duplicated between `commands/posters.js` and `commands/events.js`. Extract to a shared module. + +**Files:** +- Create: `src/lib/posters.js` +- Modify: `src/commands/posters.js` (use shared helpers) +- Modify: `src/commands/events.js` (use shared helpers) + +**Step 1: Create `src/lib/posters.js`** + +```javascript +/** + * Poster catalog helpers — shared between posters commands and events create. + */ + +const CATALOG_URL = 'https://assets.getpartiful.com/posters.json'; +let _cache = null; + +export async function fetchCatalog() { + if (_cache) return _cache; + const res = await fetch(CATALOG_URL); + if (!res.ok) throw new Error(`Failed to fetch poster catalog: HTTP ${res.status}`); + _cache = await res.json(); + return _cache; +} + +export function searchPosters(catalog, query) { + const q = query.toLowerCase(); + return catalog + .map(p => { + let score = 0; + if (p.tags.some(t => t.toLowerCase() === q)) score += 10; + if (p.tags.some(t => t.toLowerCase().includes(q))) score += 5; + if (p.name.toLowerCase().includes(q)) score += 3; + if (p.categories.some(c => c.toLowerCase().includes(q))) score += 2; + return { poster: p, score }; + }) + .filter(s => s.score > 0) + .sort((a, b) => b.score - a.score); +} + +export function buildPosterImage(poster) { + return { + source: 'partiful_posters', + poster, + url: poster.url, + blurHash: poster.blurHash, + contentType: poster.contentType, + name: poster.name, + height: poster.height, + width: poster.width, + }; +} + +export function posterThumbnail(posterId) { + return `https://partiful-posters.imgix.net/${encodeURIComponent(posterId)}?fit=max&w=400`; +} +``` + +**Step 2: Update `commands/posters.js` and `commands/events.js` to use shared helpers** + +Replace inline catalog fetch/search/build with imports from `../lib/posters.js`. + +**Step 3: Run all tests** + +```bash +npx vitest run +``` + +Expected: all pass, no behavior change. + +**Step 4: Commit** + +```bash +git add src/lib/posters.js src/commands/posters.js src/commands/events.js +git commit -m "refactor(#5): extract shared poster helpers to lib/posters.js" +``` + +--- + +## Phase 3: Custom Image Upload + +### Task 3.1: Add upload helper to `src/lib/upload.js` + +**Files:** +- Create: `src/lib/upload.js` +- Create: `tests/upload.test.js` + +**Step 1: Write the test (unit test with mock)** + +```javascript +import { describe, it, expect, vi } from 'vitest'; +import { buildUploadImage } from '../src/lib/upload.js'; + +describe('buildUploadImage', () => { + it('builds correct image object from upload data', () => { + const uploadData = { + path: 'eventImages/abc123/poster.png', + url: 'https://firebasestorage.googleapis.com/v0/b/getpartiful.appspot.com/o/eventImages%2Fabc123%2Fposter.png?alt=media', + contentType: 'image/png', + size: 123456, + width: 800, + height: 600, + }; + const filename = 'poster.png'; + const result = buildUploadImage(uploadData, filename); + expect(result.source).toBe('upload'); + expect(result.type).toBe('image'); + expect(result.upload).toEqual(uploadData); + expect(result.url).toBe(uploadData.url); + expect(result.contentType).toBe('image/png'); + expect(result.name).toBe('poster.png'); + expect(result.width).toBe(800); + expect(result.height).toBe(600); + }); +}); +``` + +**Step 2: Implement `src/lib/upload.js`** + +```javascript +/** + * Image upload helper for Partiful CLI. + * Handles uploading local files or URLs to Partiful's storage. + */ + +import { readFileSync, existsSync } from 'fs'; +import { basename, extname } from 'path'; +import { getValidToken, loadConfig, wrapPayload } from './auth.js'; + +const UPLOAD_TYPES = { + EVENT_POSTER: 'event_poster', +}; + +const ALLOWED_TYPES = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.avif': 'image/avif', +}; + +const MAX_SIZE = 10 * 1024 * 1024; // 10MB + +export async function uploadEventImage(filePath, token, config, verbose = false) { + if (!existsSync(filePath)) { + throw new Error(`File not found: ${filePath}`); + } + + const ext = extname(filePath).toLowerCase(); + const contentType = ALLOWED_TYPES[ext]; + if (!contentType) { + throw new Error(`Unsupported image type: ${ext}. Allowed: ${Object.keys(ALLOWED_TYPES).join(', ')}`); + } + + const fileBuffer = readFileSync(filePath); + if (fileBuffer.length > MAX_SIZE) { + throw new Error(`File too large: ${(fileBuffer.length / 1024 / 1024).toFixed(1)}MB. Max: ${MAX_SIZE / 1024 / 1024}MB`); + } + + const fileName = basename(filePath); + const blob = new Blob([fileBuffer], { type: contentType }); + const file = new File([blob], fileName, { type: contentType }); + + const formData = new FormData(); + formData.append('file', file); + + // Build the callable function URL with params + const baseUrl = 'https://us-central1-getpartiful.cloudfunctions.net'; + const params = new URLSearchParams({ + uploadType: UPLOAD_TYPES.EVENT_POSTER, + }); + + const res = await fetch(`${baseUrl}/uploadPhoto?${params}`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + }, + body: formData, + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Upload failed (${res.status}): ${text}`); + } + + const result = await res.json(); + return result.uploadData || result.result?.uploadData || result; +} + +export function buildUploadImage(uploadData, filename) { + return { + source: 'upload', + type: 'image', + upload: uploadData, + url: uploadData.url, + contentType: uploadData.contentType, + name: filename, + height: uploadData.height, + width: uploadData.width, + }; +} +``` + +**Step 3: Run test, commit** + +```bash +npx vitest run tests/upload.test.js +git add src/lib/upload.js tests/upload.test.js +git commit -m "feat(#5): add image upload helper" +``` + +--- + +### Task 3.2: Add `--image` flag to `events create` + +**Files:** +- Modify: `src/commands/events.js` +- Modify: `tests/events-integration.test.js` + +**Step 1: Write failing test (dry-run only — can't test real upload without API)** + +```javascript + it('events create --image validates file extension', () => { + const out = run([ + 'events', 'create', + '--title', 'Upload Test', + '--date', '2026-06-01 7pm', + '--image', '/tmp/not-an-image.txt', + '--dry-run', + ]); + expect(out.status).toBe('error'); + expect(out.error.message).toContain('Unsupported'); + }); + + it('events create errors when --poster and --image used together', () => { + const out = run([ + 'events', 'create', + '--title', 'Conflict', + '--date', '2026-06-01 7pm', + '--poster', 'piscesairbrush.png', + '--image', '/tmp/test.png', + '--dry-run', + ]); + expect(out.status).toBe('error'); + }); +``` + +**Step 2: Implement** + +Add option: `.option('--image ', 'Custom image file path to upload')` + +Add validation: + +```javascript + 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; + } + + if (opts.image) { + const { uploadEventImage, buildUploadImage } = await import('../lib/upload.js'); + const ext = opts.image.split('.').pop().toLowerCase(); + const allowed = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif']; + if (!allowed.includes(ext)) { + jsonError(`Unsupported image type: .${ext}. Allowed: ${allowed.map(e => '.' + e).join(', ')}`, 3, 'validation_error'); + return; + } + + if (globalOpts.dryRun) { + event.image = { source: 'upload', file: opts.image, note: 'File will be uploaded on real run' }; + } else { + const uploadData = await uploadEventImage(opts.image, token, config, globalOpts.verbose); + event.image = buildUploadImage(uploadData, opts.image.split('/').pop()); + } + } +``` + +**Step 3: Run tests, commit** + +```bash +npx vitest run tests/events-integration.test.js +git add src/commands/events.js tests/events-integration.test.js +git commit -m "feat(#5): add --image flag to events create" +``` + +--- + +## Phase 4: Image Support in `events update` + +### Task 4.1: Add `--poster` and `--image` to `events update` + +**Files:** +- Modify: `src/commands/events.js` +- Modify: `tests/events-integration.test.js` + +**Note:** The current `update` uses Firestore PATCH which works for flat fields but the `image` object is complex. For image updates, we'll use the `updateEvent` callable function (same pattern as `createEvent`). If that endpoint doesn't exist, we fall back to `createEvent`-style Firestore write on the `image` field. + +**Step 1: Write failing test** + +```javascript + it('events update --poster in dry-run', () => { + const out = run([ + 'events', 'update', 'test-event-123', + '--poster', 'piscesairbrush.png', + '--dry-run', + ]); + expect(out.status).toBe('success'); + expect(out.data.dryRun).toBe(true); + expect(out.data.fields).toContain('image'); + }); +``` + +**Step 2: Implement** + +Add options to update command: +```javascript + .option('--poster ', 'Set poster by ID') + .option('--poster-search ', 'Search and set best matching poster') + .option('--image ', 'Upload and set custom image') +``` + +Add image handling logic similar to create. For the Firestore PATCH, the image field needs to be serialized as a Firestore map value: + +```javascript + // Handle image options + if (opts.poster || opts.posterSearch || opts.image) { + const { fetchCatalog, searchPosters, buildPosterImage } = await import('../lib/posters.js'); + let imageObj = null; + + 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; } + imageObj = buildPosterImage(poster); + } else if (opts.posterSearch) { + const catalog = await fetchCatalog(); + const results = searchPosters(catalog, opts.posterSearch); + if (results.length === 0) { jsonError(`No posters matching "${opts.posterSearch}"`, 4, 'not_found'); return; } + imageObj = buildPosterImage(results[0].poster); + } else if (opts.image) { + if (!globalOpts.dryRun) { + const { uploadEventImage, buildUploadImage } = await import('../lib/upload.js'); + const uploadData = await uploadEventImage(opts.image, token, config, globalOpts.verbose); + imageObj = buildUploadImage(uploadData, opts.image.split('/').pop()); + } else { + imageObj = { source: 'upload', file: opts.image, note: 'File will be uploaded on real run' }; + } + } + + // Serialize image as Firestore map + fields.image = { mapValue: { fields: firestoreSerialize(imageObj) } }; + updateFields.push('image'); + } +``` + +**Note:** Firestore map serialization is complex for nested objects. We may need a helper function `firestoreSerialize` that converts a JS object to Firestore value types. If this gets too complex, use `apiRequest('POST', '/updateEvent', ...)` instead. + +**Step 3: Run tests, commit** + +```bash +npx vitest run +git add src/commands/events.js tests/events-integration.test.js +git commit -m "feat(#5): add --poster/--image to events update" +``` + +--- + +## Phase 5: Schema & Documentation + +### Task 5.1: Update schema definitions + +**Files:** +- Modify: `src/commands/schema.js` + +Add poster schemas and update event schemas: + +```javascript + 'posters.list': { + command: 'posters list', + parameters: { + '--category': { type: 'string', required: false, description: 'Filter by category' }, + '--type': { type: 'string', required: false, description: 'Filter by content type (png, gif, jpeg)' }, + '--limit': { type: 'integer', required: false, default: 20, description: 'Max results' }, + }, + }, + 'posters.search': { + command: 'posters search ', + parameters: { + query: { type: 'string', required: true, positional: true, description: 'Search query' }, + '--limit': { type: 'integer', required: false, default: 10, description: 'Max results' }, + }, + }, + 'posters.get': { + command: 'posters get ', + parameters: { + posterId: { type: 'string', required: true, positional: true, description: 'Poster ID' }, + }, + }, +``` + +Update `events.create` schema to include: + +```javascript + '--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 to upload' }, +``` + +Same for `events.update`. + +**Commit:** + +```bash +git add src/commands/schema.js +git commit -m "docs(#5): update schema with poster and image commands" +``` + +--- + +### Task 5.2: Update research docs and issue + +**Files:** +- Already created: `docs/research/2026-03-24-event-image-schema.md` + +**Step 1: Close issue #5 via PR description** + +```bash +git push -u origin sasha/event-images +gh pr create --title "feat(#5): Event images — poster library + custom upload" \ + --body "Closes #5 + +## What +- New \`posters list/search/get\` commands to browse Partiful's poster library (1,979 posters, no auth needed) +- \`--poster \` flag on \`events create/update\` to use a built-in poster +- \`--poster-search \` flag for natural language poster matching +- \`--image \` flag for custom image upload via \`uploadPhoto\` endpoint +- Updated schema introspection for all new commands +- Research doc: \`docs/research/2026-03-24-event-image-schema.md\` + +## Image Source Types +| Source | Flag | Auth | +|--------|------|------| +| Built-in posters | \`--poster\` / \`--poster-search\` | None (public catalog) | +| Custom upload | \`--image\` | Firebase token | +| Giphy GIFs | Future | API key | +| Unsplash photos | Future | API key | + +## Testing +- Poster list/search/get: integration tests (real HTTP to public catalog) +- Poster flag in events create: dry-run tests +- Upload helper: unit tests with mock +- Image validation: extension checks, size limits, mutual exclusion" +``` + +--- + +## Summary + +| Phase | Tasks | What it delivers | +|-------|-------|-----------------| +| 1 | 1.1-1.2 | `posters list/search/get` commands | +| 2 | 2.1-2.3 | `--poster` and `--poster-search` on events create | +| 3 | 3.1-3.2 | `--image` custom upload on events create | +| 4 | 4.1 | `--poster`/`--image` on events update | +| 5 | 5.1-5.2 | Schema updates, docs, PR | + +**Estimated time:** 2-3 hours +**Branch:** `sasha/event-images` +**Closes:** Issue #5 diff --git a/docs/research/2026-03-24-event-image-schema.md b/docs/research/2026-03-24-event-image-schema.md new file mode 100644 index 0000000..076d4d2 --- /dev/null +++ b/docs/research/2026-03-24-event-image-schema.md @@ -0,0 +1,101 @@ +# Partiful Event Image — API Research + +**Date:** 2026-03-24 +**Method:** Browser interception on partiful.com/create with poster selection + +--- + +## Image Field in createEvent Payload + +The `event.image` object is passed inside the `createEvent` request body alongside title, date, displaySettings, etc. + +### Poster (Built-in Library) + +```json +{ + "image": { + "source": "partiful_posters", + "poster": { + "id": "piscesairbrush.png", + "name": "piscesairbrush.png", + "contentType": "image/png", + "createdAt": "2026-02-20T23:00:36.000Z", + "version": 1771628441, + "tags": [], + "size": 5170580, + "width": 1600, + "height": 1600, + "categories": ["Trending", "Birthday"], + "url": "https://assets.getpartiful.com/posters/piscesairbrush.png", + "ordersMap": { "default": 99, "us": 99 }, + "cardOrdersMap": { "default": 99, "us": 99 }, + "bgColor": "#635ba3", + "blurHash": "eQIr2qaeC6kUgOM#W-ocsEt6LNj]y1WBnfohn+afWUWA6?j?=zahsE" + }, + "url": "https://assets.getpartiful.com/posters/piscesairbrush.png", + "blurHash": "eQIr2qaeC6kUgOM#W-ocsEt6LNj]y1WBnfohn+afWUWA6?j?=zahsE", + "contentType": "image/png", + "name": "piscesairbrush.png", + "height": 1600, + "width": 1600 + } +} +``` + +### Key Observations + +1. **`image.source`** — `"partiful_posters"` for built-in. Likely `"upload"` or `"custom"` for user uploads. +2. **`image.poster`** — Full poster object when using built-in. Contains metadata (tags, categories, dimensions, bgColor). +3. **`image.url`** — Duplicated at top level and inside `poster` object. CDN URL format: `https://assets.getpartiful.com/posters/` +4. **`image.blurHash`** — Used for placeholder rendering. Duplicated at top level. +5. **Poster IDs** — filename-style: `piscesairbrush.png`, `oscars.png`, `st-pat-day`, `movie-awards-spotlight` +6. **Posters hosted at** `https://partiful-posters.imgix.net/?fit=max&w=` (thumbnails) and `https://assets.getpartiful.com/posters/` (full res) + +## Poster Library Structure + +Posters are fetched client-side and have these fields: + +```typescript +interface Poster { + id: string; // e.g. "piscesairbrush.png" + name: string; // same as id + contentType: string; // "image/png" + createdAt: string; // ISO timestamp + version: number; // unix timestamp + tags: string[]; // search tags: ["academy awards", "oscar awards", ...] + size: number; // bytes + width: number; // pixels (usually 1600 or 2160) + height: number; // pixels + categories: string[]; // ["Trending", "Birthday", "Watch Party", "Holiday", ...] + url: string; // full-res CDN URL + ordersMap: { default: number; us: number }; // sort order + cardOrdersMap: { default: number; us: number }; // card sort order + bgColor: string; // hex color for loading state + blurHash: string; // blurhash string +} +``` + +### Categories (from web UI) + +- Trending, Birthday, Elegant, Minimal, Dinner Party, Themed, Community Made, Chill, Not Chill, Holiday, College + +### Tabs + +- **Posters** — static images (PNG) +- **GIFs** — animated (likely same structure but contentType: image/gif) +- **Photos** — stock photos (unknown source, possibly Unsplash integration) + +## Custom Image Upload (TODO — needs interception) + +The web UI has an "Upload" button and "Upload image" at bottom of picker. +Custom uploads likely: +1. Upload file to Firebase Storage or Partiful's upload endpoint +2. Get back a URL +3. Set `image.source` to something like `"upload"` or `"custom"` +4. Include `image.url`, dimensions, contentType + +## Poster Library Source + +Posters are likely fetched from Firestore directly (the web app uses Firebase). The collection is probably `posters` in the `getpartiful` Firestore database. This means we could query it via the Firestore REST API with the same auth token. + +Alternatively, we could hardcode a poster list endpoint or scrape the gallery. diff --git a/src/cli.js b/src/cli.js index e780d24..21d5bfb 100644 --- a/src/cli.js +++ b/src/cli.js @@ -9,6 +9,7 @@ import { registerWatchHelper } from './helpers/watch.js'; import { registerExportHelper } from './helpers/export.js'; import { registerShareHelper } from './helpers/share.js'; import { registerSchemaCommand } from './commands/schema.js'; +import { registerPosterCommands } from './commands/posters.js'; import { jsonOutput } from './lib/output.js'; export function run() { @@ -36,6 +37,7 @@ export function run() { registerExportHelper(program); registerShareHelper(program); registerSchemaCommand(program); + registerPosterCommands(program); program .command('version') diff --git a/src/commands/events.js b/src/commands/events.js index 626c8f4..0c7ee52 100644 --- a/src/commands/events.js +++ b/src/commands/events.js @@ -3,10 +3,12 @@ */ import { loadConfig, getValidToken, wrapPayload } from '../lib/auth.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 { 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) { @@ -19,6 +21,34 @@ async function confirm(question) { }); } +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; +} + export function registerEventsCommands(program) { const events = program.command('events').description('Manage events'); @@ -146,12 +176,33 @@ export function registerEventsCommands(program) { .option('--timezone ', 'Timezone', 'America/Los_Angeles') .option('--theme ', 'Color theme', 'oxblood') .option('--effect ', 'Visual effect', 'sunbeams') + .option('--poster ', 'Built-in poster ID (use "posters search" to find)') + .option('--poster-search ', 'Search for a poster by keyword') + .option('--image ', 'Custom image file to upload') .action(async (opts, cmd) => { const globalOpts = cmd.optsWithGlobals(); try { 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; + } + + // 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) { + 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'); + return; + } + } + const startDate = parseDateTime(opts.date, opts.timezone); const endDate = opts.endDate ? parseDateTime(opts.endDate, opts.timezone) : null; @@ -194,6 +245,48 @@ export function registerEventsCommands(program) { event.enableWaitlist = true; } + // 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]); + } 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)); + } + } + const payload = { data: wrapPayload(config, { params: { event, cohostIds: [] }, @@ -232,6 +325,9 @@ export function registerEventsCommands(program) { .option('--location ', 'New location') .option('--description ', 'New description') .option('--capacity ', 'New guest limit', parseInt) + .option('--poster ', 'Set poster by ID') + .option('--poster-search ', 'Search and set best matching poster') + .option('--image ', 'Upload and set custom image') .action(async (eventId, opts, cmd) => { const globalOpts = cmd.optsWithGlobals(); try { @@ -248,8 +344,78 @@ 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'); } + // 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; + } + + 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].poster; + } + + const imageObj = buildPosterImage(poster); + fields.image = { mapValue: { fields: toFirestoreMap(imageObj) } }; + 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'); + } + } + if (updateFields.length === 0) { - jsonError('No fields to update. Use --title, --location, --description, --date, --end-date, or --capacity', 3, 'validation_error'); + jsonError('No fields to update. Use --title, --location, --description, --date, --end-date, --capacity, --poster, --poster-search, or --image', 3, 'validation_error'); return; } diff --git a/src/commands/posters.js b/src/commands/posters.js new file mode 100644 index 0000000..20fa353 --- /dev/null +++ b/src/commands/posters.js @@ -0,0 +1,99 @@ +/** + * Poster browsing commands: list, search, get + */ + +import { fetchCatalog, searchPosters, posterThumbnail } from '../lib/posters.js'; +import { jsonOutput, jsonError } from '../lib/output.js'; + +function summarizePoster(p) { + return { + id: p.id, + name: p.name, + contentType: p.contentType, + categories: p.categories, + tags: p.tags, + width: p.width, + height: p.height, + url: p.url, + thumbnail: posterThumbnail(p.id), + bgColor: p.bgColor, + }; +} + +export function registerPosterCommands(program) { + const posters = program.command('posters').description('Browse poster catalog'); + + posters + .command('list') + .description('List available posters') + .option('--category ', 'Filter by category') + .option('--type ', 'Filter by content type (png, gif, jpeg)') + .option('--limit ', 'Max results', '20') + .action(async (opts) => { + try { + const catalog = await fetchCatalog(); + let filtered = catalog; + if (opts.category) { + const cat = opts.category.toLowerCase(); + filtered = filtered.filter(p => + p.categories && p.categories.some(c => c.toLowerCase() === cat) + ); + } + if (opts.type) { + const t = opts.type.toLowerCase(); + filtered = filtered.filter(p => + p.contentType && p.contentType.toLowerCase().includes(t) + ); + } + const limit = parseInt(opts.limit, 10); + if (isNaN(limit) || limit < 1) { + jsonError('--limit must be a positive integer', 3, 'validation_error'); + return; + } + const results = filtered.slice(0, limit).map(summarizePoster); + jsonOutput(results, { count: results.length, totalAvailable: filtered.length }); + } catch (err) { + jsonError(err.message, 5, 'internal_error'); + } + }); + + posters + .command('search ') + .description('Search posters by keyword') + .option('--limit ', 'Max results', '10') + .action(async (query, opts) => { + try { + const catalog = await fetchCatalog(); + const results = searchPosters(catalog, query); + const limit = parseInt(opts.limit, 10); + if (isNaN(limit) || limit < 1) { + jsonError('--limit must be a positive integer', 3, 'validation_error'); + return; + } + const limited = results.slice(0, limit).map(p => ({ + ...summarizePoster(p), + score: p.score, + })); + jsonOutput(limited, { count: limited.length, totalMatches: results.length }); + } catch (err) { + jsonError(err.message, 5, 'internal_error'); + } + }); + + posters + .command('get ') + .description('Get full poster details by ID') + .action(async (posterId) => { + try { + const catalog = await fetchCatalog(); + const poster = catalog.find(p => p.id === posterId); + if (!poster) { + jsonError(`Poster not found: ${posterId}`, 4, 'not_found'); + return; + } + jsonOutput(poster); + } catch (err) { + jsonError(err.message, 5, 'internal_error'); + } + }); +} diff --git a/src/commands/schema.js b/src/commands/schema.js index 36a309e..a2ca7cc 100644 --- a/src/commands/schema.js +++ b/src/commands/schema.js @@ -27,6 +27,9 @@ const SCHEMAS = { '--private': { type: 'boolean', required: false, default: false, description: 'Make event private' }, '--timezone': { type: 'string', required: false, default: 'America/Los_Angeles', description: 'Timezone' }, '--theme': { type: 'string', required: false, default: 'oxblood', description: 'Color theme' }, + '--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' }, }, }, 'events.update': { @@ -39,6 +42,9 @@ const SCHEMAS = { '--location': { type: 'string', required: false }, '--description': { type: 'string', required: false }, '--capacity': { type: 'integer', required: false }, + '--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' }, }, }, 'events.cancel': { @@ -77,6 +83,27 @@ const SCHEMAS = { '--message': { type: 'string', required: false, description: 'Message to send' }, }, }, + 'posters.list': { + command: 'posters list', + parameters: { + '--category': { type: 'string', required: false, description: 'Filter by category' }, + '--type': { type: 'string', required: false, description: 'Filter by content type (png, gif, jpeg)' }, + '--limit': { type: 'integer', required: false, default: 20, description: 'Max results' }, + }, + }, + 'posters.search': { + command: 'posters search ', + parameters: { + query: { type: 'string', required: true, positional: true, description: 'Search query' }, + '--limit': { type: 'integer', required: false, default: 10, description: 'Max results' }, + }, + }, + 'posters.get': { + command: 'posters get ', + parameters: { + posterId: { type: 'string', required: true, positional: true, description: 'Poster ID' }, + }, + }, }; export function registerSchemaCommand(program) { diff --git a/src/lib/posters.js b/src/lib/posters.js new file mode 100644 index 0000000..f6389b5 --- /dev/null +++ b/src/lib/posters.js @@ -0,0 +1,74 @@ +/** + * Shared poster catalog helpers. + */ + +let _catalogCache = null; + +export async function fetchCatalog() { + if (_catalogCache) return _catalogCache; + + // Support local fixture for testing + const localFile = process.env.PARTIFUL_POSTER_CATALOG_FILE; + if (localFile) { + const { readFileSync } = await import('fs'); + _catalogCache = JSON.parse(readFileSync(localFile, 'utf-8')); + return _catalogCache; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + try { + const res = await fetch('https://assets.getpartiful.com/posters.json', { signal: controller.signal }); + if (!res.ok) throw new Error(`Failed to fetch poster catalog: ${res.status}`); + _catalogCache = await res.json(); + return _catalogCache; + } catch (err) { + if (err.name === 'AbortError') throw new Error('Poster catalog fetch timed out (10s)'); + throw err; + } finally { + clearTimeout(timeout); + } +} + +export function posterThumbnail(posterId) { + return `https://partiful-posters.imgix.net/${encodeURIComponent(posterId)}?fit=max&w=400`; +} + +export function searchPosters(catalog, query) { + const q = query.toLowerCase(); + const results = []; + for (const poster of catalog) { + let score = 0; + // Tag exact match + if (poster.tags) { + for (const tag of poster.tags) { + if (tag.toLowerCase() === q) score += 10; + else if (tag.toLowerCase().includes(q)) score += 5; + } + } + // Name match + if (poster.name && poster.name.toLowerCase().includes(q)) score += 3; + // Category match + if (poster.categories) { + for (const cat of poster.categories) { + if (cat.toLowerCase().includes(q)) score += 2; + } + } + if (score > 0) results.push({ ...poster, score }); + } + results.sort((a, b) => b.score - a.score); + return results; +} + +export function buildPosterImage(poster) { + return { + source: 'partiful_posters', + poster, + url: poster.url, + blurHash: poster.blurHash, + contentType: poster.contentType, + name: poster.name, + height: poster.height, + width: poster.width, + }; +} diff --git a/src/lib/upload.js b/src/lib/upload.js new file mode 100644 index 0000000..90dfcc0 --- /dev/null +++ b/src/lib/upload.js @@ -0,0 +1,156 @@ +/** + * Custom image upload helper for event posters. + */ + +import { readFileSync, existsSync, statSync, writeFileSync, unlinkSync } from 'fs'; +import { basename, extname, join } from 'path'; +import { tmpdir } from 'os'; +import { randomBytes } from 'crypto'; + +const ALLOWED_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif']; +const MAX_SIZE = 10 * 1024 * 1024; // 10MB +const MIME_TYPES = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.avif': 'image/avif', +}; + +export async function uploadEventImage(filePath, token, config, verbose) { + if (!existsSync(filePath)) { + throw new Error(`File not found: ${filePath}`); + } + + const ext = extname(filePath).toLowerCase(); + if (!ALLOWED_EXTENSIONS.includes(ext)) { + throw new Error(`Unsupported image type "${ext}". Allowed types: ${ALLOWED_EXTENSIONS.join(', ')}`); + } + + const stat = statSync(filePath); + if (stat.size > MAX_SIZE) { + throw new Error(`File too large: ${(stat.size / 1024 / 1024).toFixed(1)}MB exceeds 10MB limit`); + } + + const fileData = readFileSync(filePath); + const contentType = MIME_TYPES[ext] || 'application/octet-stream'; + const blob = new Blob([fileData], { type: contentType }); + const formData = new FormData(); + formData.append('file', blob, basename(filePath)); + + const url = 'https://us-central1-getpartiful.cloudfunctions.net/uploadPhoto?uploadType=event_poster'; + + if (verbose) { + console.error(`[upload] POSTing ${basename(filePath)} (${stat.size} bytes) to uploadPhoto`); + } + + const uploadTimeoutMs = Number(config?.uploadTimeoutMs ?? 30000); + const uploadController = new AbortController(); + const uploadTimeoutId = setTimeout(() => uploadController.abort(), uploadTimeoutMs); + + let response; + try { + response = await fetch(url, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: formData, + signal: uploadController.signal, + }); + } catch (err) { + if (err.name === 'AbortError') { + throw new Error(`Upload timed out after ${uploadTimeoutMs / 1000}s`); + } + throw err; + } finally { + clearTimeout(uploadTimeoutId); + } + + if (!response.ok) { + throw new Error(`Upload failed: ${response.status} ${response.statusText}`); + } + + let result; + try { + result = await response.json(); + } catch { + throw new Error('Upload failed: invalid JSON response body'); + } + + const uploadData = result?.uploadData ?? result?.result?.uploadData; + if (!uploadData || typeof uploadData.url !== 'string' || !uploadData.url) { + throw new Error('Upload failed: missing uploadData.url in response'); + } + + return uploadData; +} + +const CONTENT_TYPE_TO_EXT = { + 'image/png': '.png', + 'image/jpeg': '.jpg', + 'image/gif': '.gif', + 'image/webp': '.webp', + 'image/avif': '.avif', +}; + +export async function downloadToTemp(url) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15000); + + let response; + try { + response = await fetch(url, { signal: controller.signal }); + } catch (err) { + clearTimeout(timeout); + if (err.name === 'AbortError') { + throw new Error(`Download timed out after 15s: ${url}`); + } + throw new Error(`Download failed: ${err.message}`); + } finally { + clearTimeout(timeout); + } + + if (!response.ok) { + throw new Error(`Download failed: ${response.status} ${response.statusText} from ${url}`); + } + + const contentType = (response.headers.get('content-type') || '').split(';')[0].trim(); + const ext = CONTENT_TYPE_TO_EXT[contentType]; + if (!ext) { + throw new Error(`Unsupported content type "${contentType}" from ${url}. Expected an image type.`); + } + + const contentLength = Number(response.headers.get('content-length') || 0); + if (contentLength && contentLength > MAX_SIZE) { + throw new Error(`Download failed: ${(contentLength / 1024 / 1024).toFixed(1)}MB exceeds 10MB limit`); + } + + const buffer = Buffer.from(await response.arrayBuffer()); + if (buffer.length > MAX_SIZE) { + throw new Error(`Download failed: ${(buffer.length / 1024 / 1024).toFixed(1)}MB exceeds 10MB limit`); + } + + const rand = randomBytes(8).toString('hex'); + const tempPath = join(tmpdir(), `partiful-upload-${rand}${ext}`); + writeFileSync(tempPath, buffer); + + return { + tempPath, + cleanup() { + try { unlinkSync(tempPath); } catch {} + }, + }; +} + +export function buildUploadImage(uploadData, filename) { + return { + source: 'upload', + type: 'image', + upload: uploadData, + url: uploadData.url, + contentType: uploadData.contentType, + name: filename, + height: uploadData.height, + width: uploadData.width, + }; +} diff --git a/tests/events-integration.test.js b/tests/events-integration.test.js index 4bf0cba..d46a875 100644 --- a/tests/events-integration.test.js +++ b/tests/events-integration.test.js @@ -42,6 +42,137 @@ describe('events integration', () => { expect(out.data.dryRun).toBe(true); expect(out.data.endpoint).toBe('/cancelEvent'); }); + + it('events create --poster includes image in payload', () => { + const out = run([ + 'events', 'create', + '--title', 'Poster Test', + '--date', '2026-06-01 7pm', + '--poster', 'piscesairbrush.png', + '--dry-run', + ]); + expect(out.status).toBe('success'); + const event = out.data.payload.data.params.event; + expect(event.image).toBeDefined(); + expect(event.image.source).toBe('partiful_posters'); + expect(event.image.poster.id).toBe('piscesairbrush.png'); + expect(event.image.url).toContain('assets.getpartiful.com'); + }); + + it('events create --poster errors on unknown poster', () => { + const { stdout } = runRaw([ + 'events', 'create', + '--title', 'Bad Poster', + '--date', '2026-06-01 7pm', + '--poster', 'nonexistent-poster-xyz', + '--dry-run', + ]); + const out = JSON.parse(stdout.trim()); + expect(out.status).toBe('error'); + expect(out.error.type).toBe('not_found'); + }); + + it('events create --poster-search finds and uses best match', () => { + const out = run([ + 'events', 'create', + '--title', 'Search Test', + '--date', '2026-06-01 7pm', + '--poster-search', 'birthday', + '--dry-run', + ]); + expect(out.status).toBe('success'); + const event = out.data.payload.data.params.event; + expect(event.image).toBeDefined(); + expect(event.image.source).toBe('partiful_posters'); + }); + + it('events create errors when both --poster and --poster-search given', () => { + const { stdout } = runRaw([ + 'events', 'create', + '--title', 'Conflict', + '--date', '2026-06-01 7pm', + '--poster', 'piscesairbrush.png', + '--poster-search', 'birthday', + '--dry-run', + ]); + const out = JSON.parse(stdout.trim()); + expect(out.status).toBe('error'); + }); + + it('events create --image validates file extension', () => { + const { stdout } = runRaw([ + 'events', 'create', + '--title', 'Upload Test', + '--date', '2026-06-01 7pm', + '--image', '/tmp/not-an-image.txt', + '--dry-run', + ]); + const out = JSON.parse(stdout); + expect(out.status).toBe('error'); + expect(out.error.message).toContain('Unsupported'); + }); + + it('events create errors when --poster and --image used together', () => { + const { stdout } = runRaw([ + 'events', 'create', + '--title', 'Conflict', + '--date', '2026-06-01 7pm', + '--poster', 'piscesairbrush.png', + '--image', '/tmp/test.png', + '--dry-run', + ]); + const out = JSON.parse(stdout); + expect(out.status).toBe('error'); + }); + + it('events create --image with URL shows download note in dry-run', () => { + const out = run([ + 'events', 'create', + '--title', 'URL Image Test', + '--date', '2026-06-01 7pm', + '--image', 'https://example.com/test.png', + '--dry-run', + ]); + expect(out.status).toBe('success'); + const image = out.data.payload.data.params.event.image; + expect(image.url).toBe('https://example.com/test.png'); + expect(image.note).toBe('URL will be downloaded and uploaded on real run'); + }); + }); + + describe('JSON envelope shape - events update', () => { + it('events update --poster in dry-run', () => { + const out = run([ + 'events', 'update', 'test-event-123', + '--poster', 'piscesairbrush.png', + '--dry-run', + ]); + expect(out.status).toBe('success'); + expect(out.data.dryRun).toBe(true); + expect(out.data.fields).toContain('image'); + }); + + it('events update --image validates extension', () => { + const { stdout } = runRaw([ + 'events', 'update', 'test-event-123', + '--image', '/tmp/bad-file.txt', + '--dry-run', + ]); + const out = JSON.parse(stdout); + expect(out.status).toBe('error'); + expect(out.error.message).toContain('Unsupported'); + }); + + it('events update --image with URL in dry-run', () => { + const out = run([ + 'events', 'update', 'test-event-123', + '--image', 'https://example.com/test.png', + '--dry-run', + ]); + expect(out.status).toBe('success'); + expect(out.data.dryRun).toBe(true); + expect(out.data.fields).toContain('image'); + }); }); describe('JSON envelope shape', () => { diff --git a/tests/fixtures/posters-catalog.json b/tests/fixtures/posters-catalog.json new file mode 100644 index 0000000..317fcb5 --- /dev/null +++ b/tests/fixtures/posters-catalog.json @@ -0,0 +1,50 @@ +[ + { + "id": "piscesairbrush.png", + "url": "https://assets.getpartiful.com/posters/piscesairbrush.png", + "name": "Pisces Airbrush", + "tags": ["zodiac", "pisces", "airbrush"], + "categories": ["astrology"], + "bgColor": "#2a1f4e", + "blurHash": "LEHV6nWB2yk8pyo0adR*.7kCMdnj", + "contentType": "image/png", + "height": 1200, + "width": 800 + }, + { + "id": "birthdaycake.png", + "url": "https://assets.getpartiful.com/posters/birthdaycake.png", + "name": "Birthday Cake", + "tags": ["birthday", "cake", "celebration"], + "categories": ["birthday"], + "bgColor": "#f5a3b5", + "blurHash": "LKO2?U%2Tw=w]~RBVZRi};RPxuwH", + "contentType": "image/png", + "height": 1200, + "width": 800 + }, + { + "id": "summersunset.png", + "url": "https://assets.getpartiful.com/posters/summersunset.png", + "name": "Summer Sunset", + "tags": ["summer", "sunset", "outdoor"], + "categories": ["seasonal"], + "bgColor": "#ff7b2e", + "blurHash": "L6Pj0^jE.AyE_3t7t7R**0o#DgR4", + "contentType": "image/png", + "height": 1200, + "width": 800 + }, + { + "id": "disconight.png", + "url": "https://assets.getpartiful.com/posters/disconight.png", + "name": "Disco Night", + "tags": ["party", "disco", "nightlife"], + "categories": ["party"], + "bgColor": "#1a0a2e", + "blurHash": "LGF5]+Yk^6#M@-5c,1J5@.%MV]WB", + "contentType": "image/png", + "height": 1200, + "width": 800 + } +] diff --git a/tests/helpers.js b/tests/helpers.js index 1656ee0..b2c03c4 100644 --- a/tests/helpers.js +++ b/tests/helpers.js @@ -2,11 +2,18 @@ import { execFileSync } from 'child_process'; import { resolve } from 'path'; const CLI = resolve('bin/partiful'); +const POSTER_CATALOG_FIXTURE = resolve('tests/fixtures/posters-catalog.json'); + +const baseEnv = { + ...process.env, + PARTIFUL_TOKEN: 'fake-token', + PARTIFUL_POSTER_CATALOG_FILE: POSTER_CATALOG_FIXTURE, +}; export function run(args, opts = {}) { const stdout = execFileSync('node', [CLI, ...args], { encoding: 'utf-8', - env: { ...process.env, PARTIFUL_TOKEN: 'fake-token', ...opts.env }, + env: { ...baseEnv, ...opts.env }, timeout: 10000, }); return JSON.parse(stdout.trim()); @@ -16,7 +23,7 @@ export function runRaw(args, opts = {}) { try { const stdout = execFileSync('node', [CLI, ...args], { encoding: 'utf-8', - env: { ...process.env, PARTIFUL_TOKEN: 'fake-token', ...opts.env }, + env: { ...baseEnv, ...opts.env }, timeout: 10000, }); return { stdout, exitCode: 0 }; diff --git a/tests/posters-integration.test.js b/tests/posters-integration.test.js new file mode 100644 index 0000000..16181a2 --- /dev/null +++ b/tests/posters-integration.test.js @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { run, runRaw } from './helpers.js'; + +describe('posters list', () => { + it('returns success with array and expected fields', () => { + const res = run(['posters', 'list', '--limit', '3']); + expect(res.status).toBe('success'); + expect(Array.isArray(res.data)).toBe(true); + expect(res.data.length).toBeLessThanOrEqual(3); + const p = res.data[0]; + expect(p).toHaveProperty('id'); + expect(p).toHaveProperty('name'); + expect(p).toHaveProperty('contentType'); + expect(p).toHaveProperty('categories'); + expect(p).toHaveProperty('tags'); + expect(p).toHaveProperty('width'); + expect(p).toHaveProperty('height'); + expect(p).toHaveProperty('url'); + expect(p).toHaveProperty('thumbnail'); + expect(p).toHaveProperty('bgColor'); + }); + + it('respects --limit', () => { + const res = run(['posters', 'list', '--limit', '5']); + expect(res.data.length).toBeLessThanOrEqual(5); + }); + + it('metadata includes count and totalAvailable', () => { + const res = run(['posters', 'list', '--limit', '2']); + expect(res.metadata).toHaveProperty('count'); + expect(res.metadata).toHaveProperty('totalAvailable'); + expect(res.metadata.count).toBe(res.data.length); + expect(res.metadata.totalAvailable).toBeGreaterThan(0); + }); + + it('filters by --category Birthday', () => { + const res = run(['posters', 'list', '--category', 'Birthday', '--limit', '50']); + expect(res.status).toBe('success'); + for (const p of res.data) { + expect(p.categories.map(c => c.toLowerCase())).toContain('birthday'); + } + }); +}); + +describe('posters search', () => { + it('finds results with score field', () => { + const res = run(['posters', 'search', 'birthday']); + expect(res.status).toBe('success'); + expect(res.data.length).toBeGreaterThan(0); + expect(res.data[0]).toHaveProperty('score'); + expect(res.data[0].score).toBeGreaterThan(0); + }); + + it('respects --limit', () => { + const res = run(['posters', 'search', 'party', '--limit', '3']); + expect(res.data.length).toBeLessThanOrEqual(3); + }); + + it('returns empty array for no matches', () => { + const res = run(['posters', 'search', 'xyzzyflurble123']); + expect(res.status).toBe('success'); + expect(res.data).toEqual([]); + }); +}); + +describe('posters get', () => { + it('returns full poster by ID', () => { + const res = run(['posters', 'get', 'piscesairbrush.png']); + expect(res.status).toBe('success'); + expect(res.data.id).toBe('piscesairbrush.png'); + }); + + it('returns not_found error for missing ID', () => { + const { stdout, exitCode } = runRaw(['posters', 'get', 'does-not-exist-xyz']); + const res = JSON.parse(stdout.trim()); + expect(res.status).toBe('error'); + expect(res.error.type).toBe('not_found'); + expect(exitCode).toBe(4); + }); +}); diff --git a/tests/upload.test.js b/tests/upload.test.js new file mode 100644 index 0000000..b8dbf1b --- /dev/null +++ b/tests/upload.test.js @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { buildUploadImage } from '../src/lib/upload.js'; + +describe('buildUploadImage', () => { + it('builds correct image object from upload data', () => { + const uploadData = { + path: 'eventImages/abc123/poster.png', + url: 'https://firebasestorage.googleapis.com/v0/b/getpartiful.appspot.com/o/eventImages%2Fabc123%2Fposter.png?alt=media', + contentType: 'image/png', + size: 123456, + width: 800, + height: 600, + }; + const result = buildUploadImage(uploadData, 'poster.png'); + expect(result.source).toBe('upload'); + expect(result.type).toBe('image'); + expect(result.upload).toEqual(uploadData); + expect(result.url).toBe(uploadData.url); + expect(result.contentType).toBe('image/png'); + expect(result.name).toBe('poster.png'); + expect(result.width).toBe(800); + expect(result.height).toBe(600); + }); +});