diff --git a/README.md b/README.md index c55067b..ea086e7 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,8 @@ Note on Docker file permissions (PUID/PGID) - 📘 [**Deployment Guide**](DEPLOYMENT_GUIDE.md) - Detailed installation instructions - Includes the new [External Media Library](DEPLOYMENT_GUIDE.md#external-media-library) reference mode +- 📚 [**Admin API (OpenAPI)**](docs/picpeak-admin-api.openapi.yaml) - Machine-readable documentation for event automation endpoints +- 🛠️ [**Admin API Quickstart**](docs/admin-api-quickstart.md) - Step-by-step authentication and testing guide for the documented endpoints - 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute - 📜 [**License**](LICENSE) - MIT License - 🔒 [**Security**](SECURITY.md) - Security policies diff --git a/backend/migrations/core/046_add_customer_contact_fields.js b/backend/migrations/core/046_add_customer_contact_fields.js new file mode 100644 index 0000000..a2ad0f3 --- /dev/null +++ b/backend/migrations/core/046_add_customer_contact_fields.js @@ -0,0 +1,44 @@ +const { addColumnIfNotExists } = require('../helpers'); + +exports.up = async function up(knex) { + await addColumnIfNotExists(knex, 'events', 'customer_name', (table) => { + table.string('customer_name'); + }); + + await addColumnIfNotExists(knex, 'events', 'customer_email', (table) => { + table.string('customer_email'); + }); + + // Backfill new columns from legacy host_* fields + const client = knex?.client?.config?.client; + + if (client === 'pg') { + await knex.raw(` + UPDATE events + SET customer_name = COALESCE(customer_name, host_name), + customer_email = COALESCE(customer_email, host_email) + `); + } else { + // SQLite fallback + await knex('events').update({ + customer_name: knex.raw('COALESCE(customer_name, host_name)'), + customer_email: knex.raw('COALESCE(customer_email, host_email)') + }); + } +}; + +exports.down = async function down(knex) { + const hasCustomerName = await knex.schema.hasColumn('events', 'customer_name'); + if (hasCustomerName) { + await knex.schema.alterTable('events', (table) => { + table.dropColumn('customer_name'); + }); + } + + const hasCustomerEmail = await knex.schema.hasColumn('events', 'customer_email'); + if (hasCustomerEmail) { + await knex.schema.alterTable('events', (table) => { + table.dropColumn('customer_email'); + }); + } +}; diff --git a/backend/src/database/db.js b/backend/src/database/db.js index ab2e137..9dd109a 100644 --- a/backend/src/database/db.js +++ b/backend/src/database/db.js @@ -63,6 +63,8 @@ async function initializeDatabase() { table.string('event_type').notNullable(); table.string('event_name').notNullable(); table.date('event_date').notNullable(); + table.string('customer_name'); + table.string('customer_email'); table.string('host_email').notNullable(); table.string('admin_email').notNullable(); table.string('password_hash').notNullable(); @@ -99,6 +101,8 @@ async function initializeDatabase() { event_type TEXT NOT NULL, event_name TEXT NOT NULL, event_date DATE NOT NULL, + customer_name TEXT, + customer_email TEXT, host_email TEXT NOT NULL, admin_email TEXT NOT NULL, password_hash TEXT NOT NULL, diff --git a/backend/src/routes/adminEvents-enhanced.js b/backend/src/routes/adminEvents-enhanced.js index 0134ded..919b97e 100644 --- a/backend/src/routes/adminEvents-enhanced.js +++ b/backend/src/routes/adminEvents-enhanced.js @@ -8,7 +8,7 @@ router.post('/', adminAuth, [ body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']), body('event_name').notEmpty().trim(), body('event_date').isDate(), - body('host_email').isEmail().normalizeEmail(), + body('customer_email').isEmail().normalizeEmail(), body('admin_email').isEmail().normalizeEmail(), body('password').notEmpty(), // Remove the weak isLength validation body('expiration_days').isInt({ min: 1, max: 365 }).optional(), @@ -16,7 +16,7 @@ router.post('/', adminAuth, [ body('color_theme').optional().trim(), body('allow_user_uploads').optional().isBoolean().toBoolean(), body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(), - body('host_name').notEmpty().trim() + body('customer_name').notEmpty().trim() ], async (req, res) => { try { console.log('Create event request body:', req.body); @@ -30,8 +30,8 @@ router.post('/', adminAuth, [ event_type, event_name, event_date, - host_name, - host_email, + customer_name, + customer_email, admin_email, password, welcome_message = '', @@ -88,8 +88,10 @@ router.post('/', adminAuth, [ event_type, event_name, event_date, - host_name, - host_email, + customer_name, + customer_email, + host_name: customer_name, + host_email: customer_email, admin_email, password_hash, welcome_message, @@ -121,4 +123,4 @@ router.post('/', adminAuth, [ console.error('Error creating event:', error); res.status(500).json({ error: 'Failed to create event' }); } -}); \ No newline at end of file +}); diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index cde9ecf..acb6834 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -37,12 +37,67 @@ const parseBooleanInput = (value, defaultValue = true) => { return defaultValue; }; +const getCustomerNameFromPayload = (payload = {}) => { + if (typeof payload.customer_name === 'string') { + const trimmed = payload.customer_name.trim(); + return trimmed || null; + } + return null; +}; + +const getCustomerEmailFromPayload = (payload = {}) => { + if (typeof payload.customer_email === 'string') { + const trimmed = payload.customer_email.trim(); + return trimmed || null; + } + return null; +}; + +const mapEventForApi = (event) => { + if (!event || typeof event !== 'object') { + return event; + } + + const { + host_name, + host_email, + customer_name, + customer_email, + ...rest + } = event; + + return { + ...rest, + customer_name: customer_name ?? host_name ?? null, + customer_email: customer_email ?? host_email ?? null + }; +}; + +let customerColumnCache = null; +const hasCustomerContactColumns = async () => { + if (customerColumnCache === true) { + return true; + } + + try { + const hasColumn = await db.schema.hasColumn('events', 'customer_email'); + if (hasColumn) { + customerColumnCache = true; + } + return hasColumn; + } catch (error) { + logger.debug('Failed to detect customer_email column', { error: error.message }); + return false; + } +}; + // Create new event router.post('/', adminAuth, [ body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']), body('event_name').notEmpty().trim(), body('event_date').isDate(), - body('host_email').isEmail().normalizeEmail(), + body('customer_name').notEmpty().trim(), + body('customer_email').isEmail().normalizeEmail(), body('admin_email').isEmail().normalizeEmail(), body('require_password').optional().isBoolean(), body('password').optional().isString().custom((value, { req }) => { @@ -73,7 +128,6 @@ router.post('/', adminAuth, [ body('color_theme').optional().trim(), body('allow_user_uploads').optional().isBoolean().toBoolean(), body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(), - body('host_name').notEmpty().trim(), body('allow_downloads').optional().isBoolean(), body('disable_right_click').optional().isBoolean(), body('watermark_downloads').optional().isBoolean(), @@ -91,8 +145,6 @@ router.post('/', adminAuth, [ event_type, event_name, event_date, - host_name, - host_email, admin_email, password, welcome_message = '', @@ -115,7 +167,16 @@ router.post('/', adminAuth, [ moderate_comments = true, show_feedback_to_guests = true } = req.body; - + + const customerName = getCustomerNameFromPayload(req.body); + const customerEmail = getCustomerEmailFromPayload(req.body); + + const customerColumnsAvailable = await hasCustomerContactColumns(); + + if (!customerName || !customerEmail) { + return res.status(400).json({ error: 'customer_name and customer_email are required' }); + } + const requirePassword = parseBooleanInput(requirePasswordInput, true); // Debug logging @@ -133,7 +194,6 @@ router.post('/', adminAuth, [ }); let passwordValidation = null; - let galleryPassword = password; if (requirePassword) { passwordValidation = await validatePasswordInContext(password, 'gallery', { @@ -148,8 +208,6 @@ router.post('/', adminAuth, [ feedback: passwordValidation.feedback }); } - } else { - galleryPassword = ''; } // Generate unique slug @@ -201,8 +259,9 @@ router.post('/', adminAuth, [ event_type, event_name, event_date, - host_name, - host_email, + ...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}), + host_name: customerName, + host_email: customerEmail, admin_email, password_hash, welcome_message, @@ -251,10 +310,12 @@ router.post('/', adminAuth, [ await db('email_queue').insert({ event_id: eventId, - recipient_email: host_email, + recipient_email: customerEmail, email_type: 'gallery_created', email_data: JSON.stringify({ - host_name: host_name, + customer_name: customerName, + customer_email: customerEmail, + host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null), event_name, event_date: event_date, // Pass raw date - will be formatted by email processor gallery_link: shareLink, @@ -272,6 +333,8 @@ router.post('/', adminAuth, [ slug, event_name, event_type, + customer_name: customerName, + customer_email: customerEmail, require_password: requirePassword, share_link: shareLink, expires_at: expires_at.toISOString(), @@ -356,7 +419,7 @@ router.get('/', adminAuth, async (req, res) => { created_at: event.created_at ? new Date(event.created_at).toISOString() : null, expires_at: event.expires_at ? new Date(event.expires_at).toISOString() : null, archived_at: event.archived_at ? new Date(event.archived_at).toISOString() : null - })); + })).map(mapEventForApi); res.json({ events: eventsWithCounts, @@ -418,7 +481,7 @@ router.get('/:id', adminAuth, async (req, res) => { .where('event_id', id) .countDistinct('ip_address as uniqueVisitors'); - res.json({ + res.json(mapEventForApi({ ...event, photo_count: parseInt(photoCount) || 0, total_size: parseInt(totalSize) || 0, @@ -426,7 +489,7 @@ router.get('/:id', adminAuth, async (req, res) => { total_downloads: parseInt(totalDownloads) || 0, unique_visitors: parseInt(uniqueVisitors) || 0, recent_photos: recentPhotos - }); + })); } catch (error) { console.error('Error fetching event:', error); res.status(500).json({ error: 'Failed to fetch event details' }); @@ -442,7 +505,8 @@ router.put('/:id', adminAuth, [ body('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(), body('color_theme').optional({ nullable: true }), body('allow_user_uploads').optional().isBoolean(), - body('host_name').optional().trim().notEmpty(), + body('customer_name').optional().trim().notEmpty(), + body('customer_email').optional().isEmail().normalizeEmail(), body('upload_category_id').optional().custom((value) => { // Accept null, undefined, or integer values if (value === null || value === undefined) return true; @@ -481,6 +545,39 @@ router.put('/:id', adminAuth, [ const { id } = req.params; const updates = { ...req.body }; + const customerColumnsAvailable = await hasCustomerContactColumns(); + + if (Object.prototype.hasOwnProperty.call(updates, 'host_name') || Object.prototype.hasOwnProperty.call(updates, 'host_email')) { + return res.status(400).json({ error: 'host_name and host_email are no longer supported. Use customer_name and customer_email instead.' }); + } + + if (Object.prototype.hasOwnProperty.call(updates, 'customer_name')) { + const nextName = getCustomerNameFromPayload(updates); + if (nextName) { + if (customerColumnsAvailable) { + updates.customer_name = nextName; + } else { + delete updates.customer_name; + } + updates.host_name = nextName; + } else { + delete updates.customer_name; + } + } + + if (Object.prototype.hasOwnProperty.call(updates, 'customer_email')) { + const nextEmail = getCustomerEmailFromPayload(updates); + if (nextEmail) { + if (customerColumnsAvailable) { + updates.customer_email = nextEmail; + } else { + delete updates.customer_email; + } + updates.host_email = nextEmail; + } else { + delete updates.customer_email; + } + } const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password'); let requirePasswordUpdate; @@ -715,10 +812,13 @@ router.post('/:id/reset-password', adminAuth, async (req, res) => { // Queue email notification if requested if (sendEmail) { - // For password reset, we'll need to create a template or use a different approach - // For now, let's use the gallery_created template with updated password - await queueEmail(id, event.host_email, 'gallery_created', { - host_name: event.host_email.split('@')[0], + const recipientEmail = event.customer_email || event.host_email; + const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null); + + await queueEmail(id, recipientEmail, 'gallery_created', { + customer_name: recipientName, + customer_email: recipientEmail, + host_name: recipientName, event_name: event.event_name, event_date: event.event_date, // Pass raw date - will be formatted by email processor gallery_link: event.share_link, @@ -773,8 +873,13 @@ router.post('/:id/resend-email', adminAuth, async (req, res) => { // Dates will be formatted by the email processor based on recipient language // Queue the email - await queueEmail(id, event.host_email, 'gallery_created', { - host_name: event.host_name || event.host_email.split('@')[0], + const recipientEmail = event.customer_email || event.host_email; + const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null); + + await queueEmail(id, recipientEmail, 'gallery_created', { + customer_name: recipientName, + customer_email: recipientEmail, + host_name: recipientName, event_name: event.event_name, event_date: event.event_date, // Pass raw date - will be formatted by email processor gallery_link: event.share_link, @@ -789,7 +894,7 @@ router.post('/:id/resend-email', adminAuth, async (req, res) => { try { await logActivity('email_resent', { email_type: 'gallery_created', - recipient: event.host_email, + recipient: recipientEmail, ip_address: req.ip || '0.0.0.0', user_agent: req.get('user-agent') || 'Unknown' }, id, { diff --git a/backend/src/routes/events.js b/backend/src/routes/events.js index b525148..a7d6c91 100644 --- a/backend/src/routes/events.js +++ b/backend/src/routes/events.js @@ -32,12 +32,66 @@ const parseBooleanInput = (value, defaultValue = true) => { return defaultValue; }; +const getCustomerNameFromPayload = (payload = {}) => { + if (typeof payload.customer_name === 'string') { + const trimmed = payload.customer_name.trim(); + return trimmed || null; + } + return null; +}; + +const getCustomerEmailFromPayload = (payload = {}) => { + if (typeof payload.customer_email === 'string') { + const trimmed = payload.customer_email.trim(); + return trimmed || null; + } + return null; +}; + +const mapEventForApi = (event) => { + if (!event || typeof event !== 'object') { + return event; + } + + const { + host_name, + host_email, + customer_name, + customer_email, + ...rest + } = event; + + return { + ...rest, + customer_name: customer_name ?? host_name ?? null, + customer_email: customer_email ?? host_email ?? null + }; +}; + +let customerColumnCache = null; +const hasCustomerContactColumns = async () => { + if (customerColumnCache === true) { + return true; + } + + try { + const hasColumn = await db.schema.hasColumn('events', 'customer_email'); + if (hasColumn) { + customerColumnCache = true; + } + return hasColumn; + } catch (error) { + return false; + } +}; + // Create new event router.post('/', adminAuth, [ body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']), body('event_name').notEmpty(), body('event_date').isDate(), - body('host_email').isEmail(), + body('customer_name').notEmpty().trim(), + body('customer_email').isEmail().normalizeEmail(), body('admin_email').isEmail(), body('require_password').optional().isBoolean(), body('password').optional().isString().custom((value, { req }) => { @@ -62,7 +116,6 @@ router.post('/', adminAuth, [ event_type, event_name, event_date, - host_email, admin_email, password, require_password: requirePasswordInput = true, @@ -71,6 +124,15 @@ router.post('/', adminAuth, [ expiration_days = 30 } = req.body; + const customerEmail = getCustomerEmailFromPayload(req.body); + const customerName = getCustomerNameFromPayload(req.body); + + if (!customerName || !customerEmail) { + return res.status(400).json({ error: 'customer_name and customer_email are required' }); + } + + const customerColumnsAvailable = await hasCustomerContactColumns(); + const requirePassword = parseBooleanInput(requirePasswordInput, true); if (requirePassword) { @@ -126,7 +188,9 @@ router.post('/', adminAuth, [ event_type, event_name, event_date, - host_email, + ...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}), + host_name: customerName, + host_email: customerEmail, admin_email, password_hash, welcome_message, @@ -141,8 +205,10 @@ router.post('/', adminAuth, [ // Queue creation email const { queueEmail } = require('../services/emailProcessor'); - await queueEmail(eventId, host_email, 'gallery_created', { - host_name: host_email.split('@')[0], // Extract name from email + await queueEmail(eventId, customerEmail, 'gallery_created', { + customer_name: customerName, + customer_email: customerEmail, + host_name: customerName, event_name, event_date: event_date, // Pass raw date - will be formatted by email processor gallery_link: fullShareLink, @@ -156,7 +222,9 @@ router.post('/', adminAuth, [ slug, share_link: fullShareLink, expires_at, - require_password: requirePassword + require_password: requirePassword, + customer_name: customerName, + customer_email: customerEmail }); } catch (error) { console.error(error); @@ -185,17 +253,27 @@ router.get('/', adminAuth, async (req, res) => { event.photo_count = photoCount.count; } - res.json(events); + res.json(events.map(mapEventForApi)); } catch (error) { res.status(500).json({ error: 'Failed to fetch events' }); } }); // Update event -router.put('/:id', adminAuth, async (req, res) => { +router.put('/:id', adminAuth, [ + body('customer_name').optional().trim().notEmpty(), + body('customer_email').optional().isEmail().normalizeEmail(), + body('require_password').optional().isBoolean() +], async (req, res) => { try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + const { id } = req.params; const updates = { ...req.body }; + const customerColumnsAvailable = await hasCustomerContactColumns(); // Don't allow updating certain fields delete updates.id; @@ -203,6 +281,38 @@ router.put('/:id', adminAuth, async (req, res) => { delete updates.created_at; delete updates.password_confirmation; + if (Object.prototype.hasOwnProperty.call(updates, 'host_name') || Object.prototype.hasOwnProperty.call(updates, 'host_email')) { + return res.status(400).json({ error: 'host_name and host_email are no longer supported. Use customer_name and customer_email instead.' }); + } + + if (Object.prototype.hasOwnProperty.call(updates, 'customer_name')) { + const nextName = getCustomerNameFromPayload(updates); + if (nextName) { + if (customerColumnsAvailable) { + updates.customer_name = nextName; + } else { + delete updates.customer_name; + } + updates.host_name = nextName; + } else { + delete updates.customer_name; + } + } + + if (Object.prototype.hasOwnProperty.call(updates, 'customer_email')) { + const nextEmail = getCustomerEmailFromPayload(updates); + if (nextEmail) { + if (customerColumnsAvailable) { + updates.customer_email = nextEmail; + } else { + delete updates.customer_email; + } + updates.host_email = nextEmail; + } else { + delete updates.customer_email; + } + } + const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password'); let requirePasswordUpdate; if (hasRequirePasswordUpdate) { diff --git a/backend/src/services/expirationChecker.js b/backend/src/services/expirationChecker.js index 4501f67..7334fb0 100644 --- a/backend/src/services/expirationChecker.js +++ b/backend/src/services/expirationChecker.js @@ -58,11 +58,15 @@ async function queueExpirationWarning(event) { const daysRemaining = Math.ceil((new Date(event.expires_at) - new Date()) / (1000 * 60 * 60 * 24)); // Determine language based on email domain - const emailLang = event.host_email.endsWith('.de') ? 'de' : 'en'; + const recipientEmail = event.customer_email || event.host_email; + const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null); + const emailLang = recipientEmail && recipientEmail.endsWith('.de') ? 'de' : 'en'; - // Queue email to host - await queueEmail(event.id, event.host_email, 'expiration_warning', { - host_name: event.host_name || event.host_email.split('@')[0], + // Queue email to customer + await queueEmail(event.id, recipientEmail, 'expiration_warning', { + customer_name: recipientName, + customer_email: recipientEmail, + host_name: recipientName, event_name: event.event_name, days_remaining: daysRemaining.toString(), expiration_date: await formatDate(event.expires_at, emailLang), @@ -78,9 +82,14 @@ async function handleExpiredEvent(event) { await db('events').where('id', event.id).update({ is_active: formatBoolean(false) }); // Queue expiration emails - await queueEmail(event.id, event.host_email, 'gallery_expired', { + const recipientEmail = event.customer_email || event.host_email; + const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null); + + await queueEmail(event.id, recipientEmail, 'gallery_expired', { event_name: event.event_name, - admin_email: event.admin_email + admin_email: event.admin_email, + customer_name: recipientName, + customer_email: recipientEmail }); // Also notify admin diff --git a/docs/admin-api-quickstart.md b/docs/admin-api-quickstart.md new file mode 100644 index 0000000..eb37a08 --- /dev/null +++ b/docs/admin-api-quickstart.md @@ -0,0 +1,147 @@ +# PicPeak Admin API Quickstart + +This guide explains how to authenticate against the PicPeak Admin API, use the OpenAPI documentation, and exercise the three automation endpoints (`create event`, `photo upload`, `resend email`) that now ship with machine-readable docs. + +> **Prerequisites** +> +> - PicPeak backend running (Docker or local `node backend/server.js`) +> - An admin account (see `data/ADMIN_CREDENTIALS.txt` for the seeded defaults) +> - API base URL (defaults to `http://localhost:3001/api`) + +--- + +## 1. Obtain an Admin API Token + +1. Determine whether reCAPTCHA is enabled in **Admin → Settings → Security**. If disabled (the default), you can skip the `recaptchaToken` field shown below. +2. Authenticate with your admin username/email and password: + +```bash +curl --fail --silent --show-error \ + -X POST "http://localhost:3001/api/auth/admin/login" \ + -H "Content-Type: application/json" \ + -d '{ + "username": "admin", + "password": "BoldTiger5872%", + "recaptchaToken": "" + }' | jq +``` + +Successful responses look like: + +```json +{ + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "user": { + "id": 1, + "username": "admin", + "email": "admin@example.com", + "mustChangePassword": false + } +} +``` + +- PicPeak also sets the `admin_token` cookie; however, when scripting you typically pass the token in an `Authorization: Bearer ` header. +- Tokens expire after 24 hours. Log in again to refresh them. + +--- + +## 2. Use the OpenAPI Documentation + +The machine-readable spec lives at `docs/picpeak-admin-api.openapi.yaml`. You can: + +- Preview it interactively with Redocly: + + ```bash + npx --yes @redocly/cli preview-docs docs/picpeak-admin-api.openapi.yaml + ``` + +- Import it into Postman, Insomnia, or VS Code REST client. +- Validate changes as part of CI with: + + ```bash + npx --yes @apidevtools/swagger-cli@4.0.4 validate docs/picpeak-admin-api.openapi.yaml + ``` + +Keep this file in sync whenever the backend endpoints evolve. + +--- + +## 3. Call the Key Admin Endpoints + +Below are minimal `curl` examples that rely on the bearer token captured earlier. + +### 3.1 Create an Event + +```bash +API_URL="http://localhost:3001/api" +TOKEN="REPLACE_WITH_JWT" + +curl --fail --silent --show-error \ + -X POST "$API_URL/admin/events" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "event_type": "wedding", + "event_name": "Emily & Jordan Celebration", + "event_date": "2025-06-07", + "customer_name": "Emily Carter", + "customer_email": "emily@example.com", + "admin_email": "studio@example.com", + "require_password": true, + "password": "Shutter123", + "expiration_days": 45 + }' | jq +``` + +### 3.2 Upload Photos to the Event + +```bash +EVENT_ID=512 + +curl --fail --silent --show-error \ + -X POST "$API_URL/admin/events/$EVENT_ID/upload" \ + -H "Authorization: Bearer $TOKEN" \ + -F "photos=@/path/to/DSC_2031.jpg" \ + -F "photos=@/path/to/DSC_2032.jpg" \ + -F "category_id=individual" | jq +``` + +- Files must be JPEG/PNG/WebP, each ≤ 50 MB. +- The per-request file count respects the `general_max_files_per_upload` admin setting (default 500). + +### 3.3 Resend the Gallery Email + +```bash +curl --fail --silent --show-error \ + -X POST "$API_URL/admin/events/$EVENT_ID/resend-email" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"password": "Shutter123"}' | jq +``` + +Omit `"password"` to send the standard security message instead. + +--- + +## 4. Quick Testing Checklist + +- ✅ Login succeeds and returns a token (HTTP 200). +- ✅ Creating an event returns `id`, `slug`, and `share_link`. +- ✅ Uploading more files than allowed returns HTTP 400 with a helpful message. +- ✅ Resending email for a missing event returns HTTP 404. +- ✅ `swagger-cli validate` passes after any spec edits. + +Automate these checks using your preferred test harness or CI pipeline to catch regressions early. + +--- + +## 5. Migrating From `host_*` + +- Run backend migrations to add the new `customer_name` / `customer_email` columns: `npm --prefix backend run migrate` (or your existing deployment flow). The migration copies legacy data automatically, so upgrades remain seamless. +- All admin APIs now require the `customer_*` fields. Older `host_*` payloads are rejected, which makes downstream client issues obvious during testing instead of silently dropping data. +- API responses still mirror `customer_*` even if migrations have not run yet (the server falls back to legacy columns until the upgrade is complete), so existing frontends can move over incrementally. +- Once every consumer writes and reads the new fields, you can safely plan the removal of the legacy `host_*` columns in a future release. + +--- + +Need deeper integration examples or language-specific SDKs? Import the OpenAPI spec into code generators such as `openapi-generator` or `orval` to scaffold API clients quickly. diff --git a/docs/picpeak-admin-api.openapi.yaml b/docs/picpeak-admin-api.openapi.yaml new file mode 100644 index 0000000..185e289 --- /dev/null +++ b/docs/picpeak-admin-api.openapi.yaml @@ -0,0 +1,584 @@ +openapi: 3.1.0 +info: + title: PicPeak Admin API + version: 1.1.11 + summary: High-level administrative endpoints for creating events, uploading photos, and resending gallery access emails. + description: | + This document describes the core administrative endpoints that power PicPeak automations. + It focuses on the three workflows requested by integrators: + + 1. Creating events with customer access credentials. + 2. Uploading photos in bulk to an event gallery. + 3. Resending the customer-facing gallery email. + + The specification follows the latest [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0) best practices + and is intended to be kept in sync with backend changes. + contact: + name: PicPeak Maintainers + url: https://github.com/the-luap/picpeak +servers: + - url: https://api.picpeak.example.com/api + description: Example production deployment + - url: http://localhost:3001/api + description: Local development +tags: + - name: Admin Events + description: Administrative endpoints for managing event galleries. +components: + securitySchemes: + CookieAuth: + type: apiKey + in: cookie + name: admin_token + description: > + Session cookie issued by the admin authentication flow. When present, the backend mirrors + it into the `Authorization` header automatically. + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: > + JSON Web Token created by the admin login endpoint. You can also pass the token explicitly + as `Authorization: Bearer ` instead of using the admin cookie. + parameters: + EventId: + name: eventId + in: path + description: Numeric identifier of the event. + required: true + schema: + type: integer + minimum: 1 + example: 341 + schemas: + ErrorResponse: + type: object + properties: + error: + type: string + description: Human readable error message. + details: + type: string + nullable: true + description: Additional context (when available). + required: + - error + example: + error: Invalid token + ValidationErrorItem: + type: object + properties: + type: + type: string + nullable: true + description: Validation error type reported by express-validator. + msg: + type: string + path: + type: string + description: Dot-delimited path to the invalid field. + value: + description: Value that failed validation. + location: + type: string + description: Location of the invalid value (always `body` for these endpoints). + required: + - msg + - path + - location + example: + type: field + msg: Event date must be a valid ISO 8601 date + path: event_date + value: 2025/05/01 + location: body + ValidationErrorResponse: + type: object + properties: + errors: + type: array + items: + $ref: '#/components/schemas/ValidationErrorItem' + required: + - errors + example: + errors: + - type: field + msg: Customer email must be a valid address + path: customer_email + value: example@invalid + location: body + CreateEventRequest: + type: object + required: + - event_type + - event_name + - event_date + - customer_name + - customer_email + - admin_email + properties: + event_type: + type: string + description: Type of event. Controls default theme and copy in the UI. + enum: [wedding, birthday, corporate, other] + event_name: + type: string + minLength: 1 + description: Display name for the gallery shown to end customers. + event_date: + type: string + format: date + description: Event date (YYYY-MM-DD). Used to calculate the default expiration. + customer_name: + type: string + minLength: 1 + description: Name of the customer receiving gallery access. + customer_email: + type: string + format: email + description: Email address of the customer who will receive the gallery link. + admin_email: + type: string + format: email + description: Admin contact email included in notification messages. + require_password: + type: boolean + default: true + description: When true, the gallery requires `password`; when false a random placeholder is stored. + password: + type: string + minLength: 6 + description: > + Gallery password issued to the customer. Required when `require_password` is `true`. + Left unset to auto-generate a placeholder when password protection is disabled. + expiration_days: + type: integer + minimum: 1 + maximum: 365 + default: 30 + description: Number of days after the event date before the gallery expires. + welcome_message: + type: string + description: Optional welcome message displayed in the gallery. + color_theme: + type: string + nullable: true + description: Optional theme identifier or CSS color settings. + allow_user_uploads: + type: boolean + default: false + description: Allow gallery guests to upload their own photos. + upload_category_id: + type: integer + nullable: true + description: ID of the default category for user uploads. + allow_downloads: + type: boolean + default: true + description: Allow guests to download photos. + disable_right_click: + type: boolean + default: false + description: Disable right-click in the gallery view. + watermark_downloads: + type: boolean + default: false + description: Enable watermarking on downloaded images. + watermark_text: + type: string + nullable: true + description: Custom watermark text when `watermark_downloads` is true. + feedback_enabled: + type: boolean + default: false + description: Enable the feedback module for this gallery. + allow_ratings: + type: boolean + default: true + allow_likes: + type: boolean + default: true + allow_comments: + type: boolean + default: true + allow_favorites: + type: boolean + default: true + require_name_email: + type: boolean + default: false + description: Require guests to provide name and email when leaving feedback. + moderate_comments: + type: boolean + default: true + description: Hold guest comments for moderation. + show_feedback_to_guests: + type: boolean + default: true + description: Display aggregated feedback metrics back to guests. + example: + event_type: wedding + event_name: Emily & Jordan Celebration + event_date: 2025-06-07 + customer_name: Emily Carter + customer_email: emily@example.com + admin_email: studio@example.com + require_password: true + password: Shutter123 + expiration_days: 45 + welcome_message: > + We loved capturing your day! Use the password below to view and download your photos. + allow_user_uploads: false + allow_downloads: true + feedback_enabled: true + allow_comments: true + show_feedback_to_guests: true + EventSummary: + type: object + properties: + id: + type: integer + description: Database identifier of the newly created event. + slug: + type: string + description: Unique slug used to build the gallery URL. + event_name: + type: string + event_type: + type: string + enum: [wedding, birthday, corporate, other] + customer_name: + type: string + nullable: true + description: Name of the customer associated with the event. + customer_email: + type: string + format: email + nullable: true + description: Email address of the customer associated with the event. + require_password: + type: boolean + share_link: + type: string + description: Absolute or relative URL guests can use to reach the gallery. + expires_at: + type: string + format: date-time + description: ISO 8601 timestamp when the gallery expires. + created_at: + type: string + format: date-time + description: ISO 8601 timestamp when the event was created. + required: + - id + - slug + - event_name + - event_type + - require_password + - share_link + - expires_at + - created_at + example: + id: 512 + slug: wedding-emily-jordan-2025-06-07 + event_name: Emily & Jordan Celebration + event_type: wedding + customer_name: Emily Carter + customer_email: emily@example.com + require_password: true + share_link: https://app.picpeak.io/gallery/wedding-emily-jordan-2025-06-07/2f3c8a4d90bb11ef9b2e0242ac120002 + expires_at: 2025-07-22T00:00:00.000Z + created_at: 2025-05-01T14:32:45.000Z + UploadPhotosResponse: + type: object + properties: + message: + type: string + photos: + type: array + items: + $ref: '#/components/schemas/UploadedPhotoSummary' + description: Metadata for each photo that was persisted successfully. + totalFiles: + type: integer + minimum: 0 + description: Total number of files included in the request (valid + invalid). + successCount: + type: integer + minimum: 0 + failureCount: + type: integer + minimum: 0 + errors: + type: array + items: + $ref: '#/components/schemas/UploadFailure' + description: Present when some files failed validation or processing. + required: + - message + - photos + - totalFiles + - successCount + - failureCount + example: + message: Uploaded 18 of 20 photos. 2 failed. + photos: + - id: 9821 + filename: DSC_2031.jpg + size: 4812096 + category_id: 2 + - id: 9822 + filename: DSC_2032.jpg + size: 5216743 + category_id: 2 + totalFiles: 20 + successCount: 18 + failureCount: 2 + errors: + - filename: DSC_2020.raw + error: Only JPEG, PNG and WebP images are allowed + - filename: portrait.png + error: File is empty + UploadedPhotoSummary: + type: object + properties: + id: + type: integer + filename: + type: string + size: + type: integer + description: File size in bytes. + category_id: + type: integer + nullable: true + required: + - id + - filename + - size + example: + id: 9821 + filename: DSC_2031.jpg + size: 4812096 + category_id: 2 + UploadFailure: + type: object + properties: + filename: + type: string + error: + type: string + required: + - filename + - error + example: + filename: DSC_2031.gif + error: Only JPEG, PNG and WebP images are allowed + ResendEmailRequest: + type: object + properties: + password: + type: string + minLength: 1 + description: > + Optional plain-text password to include in the email. When omitted a security notice + placeholder is inserted because the stored hash cannot be reversed. + example: + password: Shutter123 + ResendEmailResponse: + type: object + properties: + success: + type: boolean + message: + type: string + required: + - success + - message + example: + success: true + message: Creation email has been queued for sending +paths: + /admin/events: + post: + tags: [Admin Events] + operationId: createAdminEvent + summary: Create a new event + description: > + Creates a new event, provisions storage folders, stores the gallery password, and queues + the initial gallery email for the customer. Requires admin authentication. + security: + - CookieAuth: [] + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateEventRequest' + examples: + weddingExample: + summary: Wedding with password protection + value: + event_type: wedding + event_name: Emily & Jordan Celebration + event_date: 2025-06-07 + customer_name: Emily Carter + customer_email: emily@example.com + admin_email: studio@example.com + require_password: true + password: Shutter123 + expiration_days: 45 + welcome_message: > + We loved capturing your day! Use the password below to view and download your photos. + allow_user_uploads: false + allow_downloads: true + feedback_enabled: true + allow_comments: true + show_feedback_to_guests: true + responses: + '200': + description: Event created successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/EventSummary' + '400': + description: Validation failed. At least one field is invalid or missing. + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + '401': + description: Authentication required or token invalid. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Unexpected server error while creating the event. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /admin/events/{eventId}/upload: + post: + tags: [Admin Events] + operationId: uploadEventPhotos + summary: Upload photos to an event gallery + description: | + Uploads one or more photos to the specified event. Files are validated, moved into the + event storage directory, and thumbnails are generated asynchronously. + + The maximum number of files per upload is controlled via the `general_max_files_per_upload` + setting (default 500, capped at 2000). Files exceeding 50 MB are rejected. + security: + - CookieAuth: [] + - BearerAuth: [] + parameters: + - $ref: '#/components/parameters/EventId' + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + photos: + type: array + description: > + One or more image files (JPEG, PNG, WebP). Each file must be <= 50 MB. + items: + type: string + format: binary + category_id: + oneOf: + - type: integer + - type: string + description: > + Optional category assignment. Accepts numeric IDs or the string values `collage` + and `individual` for backward compatibility. + required: + - photos + encoding: + photos: + style: form + explode: false + responses: + '200': + description: Upload completed. Failed files (if any) are listed in the response. + content: + application/json: + schema: + $ref: '#/components/schemas/UploadPhotosResponse' + '400': + description: Request failed validation (invalid files, too many files, etc.). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Authentication required or token invalid. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: The referenced event does not exist. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Unexpected server error while processing uploads. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /admin/events/{eventId}/resend-email: + post: + tags: [Admin Events] + operationId: resendEventEmail + summary: Resend the gallery access email to the customer + description: > + Queues the standard `gallery_created` email for the event's customer. Useful when resending + credentials to the customer or communicating an updated password. Requires admin authentication. + security: + - CookieAuth: [] + - BearerAuth: [] + parameters: + - $ref: '#/components/parameters/EventId' + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/ResendEmailRequest' + example: + password: NewSecurePassword! + responses: + '200': + description: Email successfully queued for delivery. + content: + application/json: + schema: + $ref: '#/components/schemas/ResendEmailResponse' + '401': + description: Authentication required or token invalid. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Event not found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Unexpected server error while queuing the email. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' diff --git a/frontend/src/pages/admin/AdminLoginPage.tsx b/frontend/src/pages/admin/AdminLoginPage.tsx index 66553c2..78ea0f3 100644 --- a/frontend/src/pages/admin/AdminLoginPage.tsx +++ b/frontend/src/pages/admin/AdminLoginPage.tsx @@ -68,6 +68,7 @@ export const AdminLoginPage: React.FC = () => { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + toast.dismiss(); if (!validateForm()) { return; diff --git a/frontend/src/pages/admin/CreateEventPage.tsx b/frontend/src/pages/admin/CreateEventPage.tsx index bc00785..9ddb131 100644 --- a/frontend/src/pages/admin/CreateEventPage.tsx +++ b/frontend/src/pages/admin/CreateEventPage.tsx @@ -25,7 +25,7 @@ interface FormData { event_type: string; event_name: string; event_date: string; - host_email: string; + customer_email: string; admin_email: string; require_password: boolean; password: string; @@ -122,7 +122,7 @@ export const CreateEventPage: React.FC = () => { event_type: 'wedding', event_name: '', event_date: format(new Date(), 'yyyy-MM-dd'), - host_email: '', + customer_email: '', admin_email: '', require_password: true, password: '', @@ -198,10 +198,10 @@ export const CreateEventPage: React.FC = () => { newErrors.event_name = t('validation.eventNameRequired'); } - if (!formData.host_email) { - newErrors.host_email = t('validation.hostEmailRequired'); - } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.host_email)) { - newErrors.host_email = t('validation.invalidEmailFormat'); + if (!formData.customer_email) { + newErrors.customer_email = t('validation.hostEmailRequired'); + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.customer_email)) { + newErrors.customer_email = t('validation.invalidEmailFormat'); } if (!formData.admin_email) { @@ -245,7 +245,8 @@ export const CreateEventPage: React.FC = () => { event_type: formData.event_type, event_name: formData.event_name, event_date: formData.event_date, - host_email: formData.host_email, + customer_name: formData.customer_email.split('@')[0], + customer_email: formData.customer_email, admin_email: formData.admin_email, require_password: formData.require_password, password: formData.require_password ? formData.password : undefined, @@ -390,15 +391,15 @@ export const CreateEventPage: React.FC = () => {
{/* Customer Email */}
-
diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index 74c13ae..f1d3981 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -122,7 +122,7 @@ export const EventDetailsPage: React.FC = () => { allow_user_uploads: boolean; upload_category_id: number | null; hero_photo_id: number | null; - host_name: string; + customer_name: string; source_mode: 'managed' | 'reference'; external_path: string; require_password: boolean; @@ -138,7 +138,7 @@ export const EventDetailsPage: React.FC = () => { allow_user_uploads: false, upload_category_id: null, hero_photo_id: null, - host_name: '', + customer_name: '', source_mode: 'managed', external_path: '', require_password: true, @@ -282,7 +282,7 @@ export const EventDetailsPage: React.FC = () => { allow_user_uploads: event.allow_user_uploads || false, upload_category_id: event.upload_category_id || null, hero_photo_id: event.hero_photo_id || null, - host_name: event.host_name || '', + customer_name: event.customer_name || '', source_mode: event.source_mode === 'reference' ? 'reference' : 'managed', external_path: event.external_path || '', require_password: normalizeRequirePassword(event.require_password), @@ -389,8 +389,8 @@ export const EventDetailsPage: React.FC = () => { updateData.external_path = editForm.source_mode === 'reference' ? externalPathToSave : null; - if (editForm.host_name !== undefined && editForm.host_name !== null) { - updateData.host_name = editForm.host_name; + if (editForm.customer_name !== undefined && editForm.customer_name !== null) { + updateData.customer_name = editForm.customer_name; } if (editForm.new_password) { @@ -665,8 +665,8 @@ export const EventDetailsPage: React.FC = () => { setEditForm(prev => ({ ...prev, host_name: e.target.value }))} + value={editForm.customer_name} + onChange={(e) => setEditForm(prev => ({ ...prev, customer_name: e.target.value }))} placeholder={t('events.hostNamePlaceholder')} />
@@ -881,14 +881,14 @@ export const EventDetailsPage: React.FC = () => {
{t('events.hostName')}
- {event.host_name || {t('common.notSet')}} + {event.customer_name || {t('common.notSet')}}
{t('events.hostEmail')}
-
{event.host_email}
+
{event.customer_email}
diff --git a/frontend/src/pages/admin/EventsListPage.tsx b/frontend/src/pages/admin/EventsListPage.tsx index bf82dc1..d48a072 100644 --- a/frontend/src/pages/admin/EventsListPage.tsx +++ b/frontend/src/pages/admin/EventsListPage.tsx @@ -159,7 +159,7 @@ export const EventsListPage: React.FC = () => { events = events.filter(e => e.event_name.toLowerCase().includes(term) || e.event_type.toLowerCase().includes(term) || - e.host_email.toLowerCase().includes(term) + (e.customer_email || '').toLowerCase().includes(term) ); } @@ -428,7 +428,7 @@ export const EventsListPage: React.FC = () => {

{event.event_name}

-

{event.host_email}

+

{event.customer_email}

({ - ...event, - require_password: normalizeRequirePassword((event as any)?.require_password, true), -}); +const normalizeEvent = (event: Event): Event => { + const legacyHostName = (event as any)?.host_name; + const legacyHostEmail = (event as any)?.host_email; + + const customerName = event.customer_name ?? legacyHostName ?? undefined; + const customerEmail = event.customer_email ?? legacyHostEmail ?? ''; + + return { + ...event, + customer_name: customerName, + customer_email: customerEmail, + require_password: normalizeRequirePassword((event as any)?.require_password, true), + }; +}; interface CreateEventData { event_type: string; event_name: string; event_date: string; - host_email: string; + customer_name?: string; + customer_email: string; admin_email: string; require_password?: boolean; password?: string; @@ -33,7 +44,8 @@ interface CreateEventData { interface UpdateEventData { event_name?: string; event_date?: string; - host_email?: string; + customer_name?: string; + customer_email?: string; admin_email?: string; require_password?: boolean; password?: string; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 43ecf85..b726dee 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -5,8 +5,8 @@ export interface Event { event_type: string; event_name: string; event_date: string; - host_name?: string; - host_email: string; + customer_name?: string; + customer_email: string; admin_email: string; welcome_message?: string; color_theme?: string; diff --git a/tests/e2e/auth-smoke.spec.ts b/tests/e2e/auth-smoke.spec.ts index 6e342a8..24cd833 100644 --- a/tests/e2e/auth-smoke.spec.ts +++ b/tests/e2e/auth-smoke.spec.ts @@ -32,8 +32,8 @@ async function createEventWithPhotos(page: Page) { event_type: 'wedding', event_name: eventName, event_date: eventDate, - host_name: 'Playwright Host', - host_email: 'host@example.com', + customer_name: 'Playwright Host', + customer_email: 'host@example.com', admin_email: ADMIN_EMAIL, password: GALLERY_PASSWORD, expiration_days: 30, diff --git a/tests/e2e/external-media-gallery.spec.ts b/tests/e2e/external-media-gallery.spec.ts index 6898164..fdc571e 100644 --- a/tests/e2e/external-media-gallery.spec.ts +++ b/tests/e2e/external-media-gallery.spec.ts @@ -46,8 +46,8 @@ async function createExternalGallery(page) { event_type: 'wedding', event_name: eventName, event_date: eventDate, - host_name: 'External Host', - host_email: 'host@example.com', + customer_name: 'External Host', + customer_email: 'host@example.com', admin_email: ADMIN_EMAIL, password: GALLERY_PASSWORD, expiration_days: 30, diff --git a/tests/e2e/gallery-feedback-filter.spec.ts b/tests/e2e/gallery-feedback-filter.spec.ts index 7f865af..0dcdcce 100644 --- a/tests/e2e/gallery-feedback-filter.spec.ts +++ b/tests/e2e/gallery-feedback-filter.spec.ts @@ -43,8 +43,8 @@ async function createGalleryWithModeratedComments(page: Page): Promise { event_type: 'wedding', event_name: eventName, event_date: eventDate, - host_name: 'Notification Test', - host_email: 'notify@example.com', + customer_name: 'Notification Test', + customer_email: 'notify@example.com', admin_email: ADMIN_EMAIL, password: 'NotifyClearPass!1', expiration_days: 30,