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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,033 changes: 1,033 additions & 0 deletions docs/plans/2026-03-24-event-images-implementation.md

Large diffs are not rendered by default.

101 changes: 101 additions & 0 deletions docs/research/2026-03-24-event-image-schema.md
Original file line number Diff line number Diff line change
@@ -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/<id>`
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/<id>?fit=max&w=<width>` (thumbnails) and `https://assets.getpartiful.com/posters/<id>` (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.
2 changes: 2 additions & 0 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -36,6 +37,7 @@ export function run() {
registerExportHelper(program);
registerShareHelper(program);
registerSchemaCommand(program);
registerPosterCommands(program);

program
.command('version')
Expand Down
168 changes: 167 additions & 1 deletion src/commands/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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');

Expand Down Expand Up @@ -146,12 +176,33 @@ export function registerEventsCommands(program) {
.option('--timezone <tz>', 'Timezone', 'America/Los_Angeles')
.option('--theme <theme>', 'Color theme', 'oxblood')
.option('--effect <effect>', 'Visual effect', 'sunbeams')
.option('--poster <posterId>', 'Built-in poster ID (use "posters search" to find)')
.option('--poster-search <query>', 'Search for a poster by keyword')
.option('--image <path>', '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;

Expand Down Expand Up @@ -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 <term>" 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 <term>".`, 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: [] },
Expand Down Expand Up @@ -232,6 +325,9 @@ export function registerEventsCommands(program) {
.option('--location <location>', 'New location')
.option('--description <desc>', 'New description')
.option('--capacity <n>', 'New guest limit', parseInt)
.option('--poster <posterId>', 'Set poster by ID')
.option('--poster-search <query>', 'Search and set best matching poster')
.option('--image <path>', 'Upload and set custom image')
.action(async (eventId, opts, cmd) => {
const globalOpts = cmd.optsWithGlobals();
try {
Expand All @@ -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 <term>" 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;
}

Expand Down
Loading