Add customer contact fields and admin API docs (refs #41)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = '',
|
||||
@@ -116,6 +168,15 @@ router.post('/', adminAuth, [
|
||||
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, {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <token>` 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.
|
||||
@@ -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 <token>` 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'
|
||||
@@ -68,6 +68,7 @@ export const AdminLoginPage: React.FC = () => {
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
toast.dismiss();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
|
||||
@@ -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 = () => {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Customer Email */}
|
||||
<div>
|
||||
<label htmlFor="host_email" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
<label htmlFor="customer_email" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.hostEmail')}
|
||||
</label>
|
||||
<Input
|
||||
id="host_email"
|
||||
id="customer_email"
|
||||
type="email"
|
||||
value={formData.host_email}
|
||||
onChange={handleInputChange('host_email')}
|
||||
error={errors.host_email}
|
||||
value={formData.customer_email}
|
||||
onChange={handleInputChange('customer_email')}
|
||||
error={errors.customer_email}
|
||||
placeholder={t('events.hostEmailPlaceholder')}
|
||||
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
|
||||
@@ -27,8 +27,8 @@ interface FormData {
|
||||
event_type: string;
|
||||
event_name: string;
|
||||
event_date: string;
|
||||
host_name: string;
|
||||
host_email: string;
|
||||
customer_name: string;
|
||||
customer_email: string;
|
||||
admin_email: string;
|
||||
require_password: boolean;
|
||||
password: string;
|
||||
@@ -86,8 +86,8 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
event_type: 'wedding',
|
||||
event_name: '',
|
||||
event_date: new Date().toISOString().split('T')[0], // Initialize with ISO date format
|
||||
host_name: '',
|
||||
host_email: '',
|
||||
customer_name: '',
|
||||
customer_email: '',
|
||||
admin_email: '',
|
||||
require_password: true,
|
||||
password: '',
|
||||
@@ -184,14 +184,14 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
newErrors.event_date = t('validation.eventDateRequired');
|
||||
}
|
||||
|
||||
if (!formData.host_name) {
|
||||
newErrors.host_name = t('validation.hostNameRequired');
|
||||
if (!formData.customer_name) {
|
||||
newErrors.customer_name = t('validation.hostNameRequired');
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -236,8 +236,8 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
event_type: formData.event_type,
|
||||
event_name: formData.event_name,
|
||||
event_date: formData.event_date,
|
||||
host_name: formData.host_name,
|
||||
host_email: formData.host_email,
|
||||
customer_name: formData.customer_name,
|
||||
customer_email: formData.customer_email,
|
||||
admin_email: formData.admin_email,
|
||||
require_password: formData.require_password,
|
||||
password: formData.require_password ? formData.password : undefined,
|
||||
@@ -472,9 +472,9 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
<Input
|
||||
label={t('events.hostName')}
|
||||
placeholder={t('events.hostNamePlaceholder')}
|
||||
value={formData.host_name}
|
||||
onChange={handleInputChange('host_name')}
|
||||
error={errors.host_name}
|
||||
value={formData.customer_name}
|
||||
onChange={handleInputChange('customer_name')}
|
||||
error={errors.customer_name}
|
||||
leftIcon={<Calendar className="w-5 h-5" />}
|
||||
/>
|
||||
|
||||
@@ -482,9 +482,9 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
type="email"
|
||||
label={t('events.hostEmail')}
|
||||
placeholder={t('events.hostEmailPlaceholder')}
|
||||
value={formData.host_email}
|
||||
onChange={handleInputChange('host_email')}
|
||||
error={errors.host_email}
|
||||
value={formData.customer_email}
|
||||
onChange={handleInputChange('customer_email')}
|
||||
error={errors.customer_email}
|
||||
leftIcon={<Mail className="w-5 h-5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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 = () => {
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={editForm.host_name}
|
||||
onChange={(e) => 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')}
|
||||
/>
|
||||
</div>
|
||||
@@ -881,14 +881,14 @@ export const EventDetailsPage: React.FC = () => {
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-neutral-500">{t('events.hostName')}</dt>
|
||||
<dd className="mt-1 text-sm text-neutral-900">
|
||||
{event.host_name || <span className="text-neutral-400">{t('common.notSet')}</span>}
|
||||
{event.customer_name || <span className="text-neutral-400">{t('common.notSet')}</span>}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-neutral-500">{t('events.hostEmail')}</dt>
|
||||
<dd className="mt-1 text-sm text-neutral-900">{event.host_email}</dd>
|
||||
<dd className="mt-1 text-sm text-neutral-900">{event.customer_email}</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -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 = () => {
|
||||
<td className="px-6 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-900">{event.event_name}</p>
|
||||
<p className="text-xs text-neutral-500">{event.host_email}</p>
|
||||
<p className="text-xs text-neutral-500">{event.customer_email}</p>
|
||||
<div className="mt-1">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ${
|
||||
|
||||
@@ -2,16 +2,27 @@ import { api } from '../config/api';
|
||||
import type { Event } from '../types';
|
||||
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||
|
||||
const normalizeEvent = (event: Event): Event => ({
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -43,8 +43,8 @@ async function createGalleryWithModeratedComments(page: Page): Promise<GallerySe
|
||||
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,
|
||||
|
||||
@@ -32,8 +32,8 @@ async function ensureGalleryWithPhotos(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: 90,
|
||||
|
||||
@@ -27,8 +27,8 @@ test('clearing old notifications removes read entries', async ({ request }) => {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user