Add customer contact fields and admin API docs (refs #41)

This commit is contained in:
Paul Nothaft
2025-10-14 18:29:21 +02:00
parent 8f297e25c4
commit 775c5159ea
21 changed files with 1124 additions and 103 deletions
+2
View File
@@ -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');
});
}
};
+4
View File
@@ -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 -6
View File
@@ -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,
+127 -22
View File
@@ -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, {
+118 -8
View File
@@ -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) {
+15 -6
View File
@@ -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
+147
View File
@@ -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 ≤ 50MB.
- 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.
+584
View File
@@ -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;
+13 -12
View File
@@ -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>
+2 -2
View File
@@ -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 ${
+16 -4
View File
@@ -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;
+2 -2
View File
@@ -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;
+2 -2
View File
@@ -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,
+2 -2
View File
@@ -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,
+2 -2
View File
@@ -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,
+2 -2
View File
@@ -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,
+2 -2
View File
@@ -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,