feat: draft mode, admin branding, and workflow improvements

Draft Mode:
- Events are created as drafts by default — no email sent until published
- Add "Publish & Notify Client" button with confirmation dialog
- Draft banner with yellow styling on event details page
- Draft filter tab in events list
- Gallery middleware blocks public access to draft events
- Migration 076 adds is_draft column to events table

Admin Draft Preview:
- Admins can preview draft galleries via JWT preview token (?preview=)
- "View Gallery" link on drafts auto-appends preview token

Admin & Login Page Branding:
- Admin header uses configured company logo/name from branding settings
- Login page shows configured logo instead of hardcoded PicPeak
- Respects logo_display_mode (logo_only, text_only, logo_and_text)

OG Tag Branding:
- DynamicFavicon component updates OG meta tags and page title from
  branding settings

Editable Client Email:
- Customer email is now editable after event creation in edit mode

Branding Inheritance:
- New events inherit hero logo settings (visibility, size, position)
  from global branding configuration

Share Link Full Domain URL:
- New getFrontendBaseUrl() utility with DB fallback to general_site_url
- Used in email processor and share link service
This commit is contained in:
Paul Nothaft
2026-04-08 11:42:38 +02:00
parent 125cd0d003
commit 40332a71db
19 changed files with 456 additions and 62 deletions
@@ -0,0 +1,21 @@
/**
* Migration to add is_draft column to events table.
* Draft events are not visible to gallery visitors until published.
*/
exports.up = async function(knex) {
const hasColumn = await knex.schema.hasColumn('events', 'is_draft');
if (!hasColumn) {
await knex.schema.alterTable('events', (table) => {
table.boolean('is_draft').defaultTo(false);
});
}
};
exports.down = async function(knex) {
const hasColumn = await knex.schema.hasColumn('events', 'is_draft');
if (hasColumn) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('is_draft');
});
}
};
+39 -17
View File
@@ -4,6 +4,18 @@ const { formatBoolean } = require('../utils/dbCompat');
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
const logger = require('../utils/logger');
// Check if the request carries a valid admin preview token (Feature 3)
function isAdminPreview(req) {
const previewToken = req.query?.preview;
if (!previewToken) return false;
try {
const decoded = jwt.verify(previewToken, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
return decoded.type === 'admin';
} catch {
return false;
}
}
// Middleware to verify gallery access
async function verifyGalleryAccess(req, res, next) {
try {
@@ -16,15 +28,18 @@ async function verifyGalleryAccess(req, res, next) {
return res.status(401).json({ error: 'No token provided' });
}
const adminPreview = isAdminPreview(req);
event = await withRetry(async () => {
return await db('events')
.where({
const q = db('events')
.where({
slug: requestedSlug,
is_active: formatBoolean(true),
is_archived: formatBoolean(false)
})
.select('*')
.first();
});
if (!adminPreview) {
q.where({ is_draft: formatBoolean(false) });
}
return await q.select('*').first();
});
if (!event) {
@@ -66,15 +81,18 @@ async function verifyGalleryAccess(req, res, next) {
// If we have a slug in the URL params or from pre-middleware, verify it matches
if (requestedSlug) {
// Verify by slug and ensure it matches the token's event
const adminPreviewToken = isAdminPreview(req);
event = await withRetry(async () => {
return await db('events')
.where({
const q = db('events')
.where({
slug: requestedSlug,
is_active: formatBoolean(true),
is_archived: formatBoolean(false)
})
.select('*')
.first();
});
if (!adminPreviewToken) {
q.where({ is_draft: formatBoolean(false) });
}
return await q.select('*').first();
});
// Verify the token's eventId matches
@@ -83,15 +101,18 @@ async function verifyGalleryAccess(req, res, next) {
}
} else {
// Fallback to using eventId from token
const adminPreviewFallback = isAdminPreview(req);
event = await withRetry(async () => {
return await db('events')
.where({
id: decoded.eventId,
const q = db('events')
.where({
id: decoded.eventId,
is_active: formatBoolean(true),
is_archived: formatBoolean(false)
})
.select('*')
.first();
});
if (!adminPreviewFallback) {
q.where({ is_draft: formatBoolean(false) });
}
return await q.select('*').first();
});
}
@@ -122,5 +143,6 @@ async function verifyGalleryAccess(req, res, next) {
}
module.exports = {
verifyGalleryAccess
verifyGalleryAccess,
isAdminPreview
};
+123 -6
View File
@@ -21,6 +21,7 @@ const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
const eventTypeService = require('../services/eventTypeService');
const { validateFileType } = require('../utils/fileSecurityUtils');
const { requireEventOwnership } = require('../middleware/ownership');
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
// Shared validator for hero_image_anchor accepts legacy keywords or "X% Y%" focal point
const validateHeroImageAnchor = (value) => {
@@ -111,6 +112,50 @@ const getEventFieldRequirements = async () => {
}
};
// Helper to get branding defaults for new events (Feature 7: Branding Inheritance)
const getBrandingDefaults = async () => {
try {
const settings = await db('app_settings')
.whereIn('setting_key', [
'branding_logo_display_hero',
'branding_logo_size',
'branding_logo_position'
])
.select('setting_key', 'setting_value');
const defaults = {
hero_logo_visible: true,
hero_logo_size: 'medium',
hero_logo_position: 'top'
};
settings.forEach(s => {
let value = s.setting_value;
if (typeof value === 'string') {
try { value = JSON.parse(value); } catch (e) { /* use as-is */ }
}
if (s.setting_key === 'branding_logo_display_hero') {
defaults.hero_logo_visible = value !== false;
}
if (s.setting_key === 'branding_logo_size' && value) {
defaults.hero_logo_size = value;
}
if (s.setting_key === 'branding_logo_position' && value) {
defaults.hero_logo_position = value;
}
});
return defaults;
} catch (error) {
logger.error('Failed to get branding defaults', { error: error.message });
return {
hero_logo_visible: true,
hero_logo_size: 'medium',
hero_logo_position: 'top'
};
}
};
// Use parseStringInput from shared parsers for customer data extraction
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email);
@@ -267,7 +312,9 @@ router.post('/', adminAuth, requirePermission('events.create'), [
photo_cap = null,
// Client access settings (#172)
client_access_enabled = false,
client_password = null
client_password = null,
// Draft mode
is_draft = true
} = req.body;
const customerName = getCustomerNameFromPayload(req.body);
@@ -396,6 +443,12 @@ router.post('/', adminAuth, requirePermission('events.create'), [
}
}
// Get branding defaults for hero logo settings (Feature 7: Branding Inheritance)
const brandingDefaults = await getBrandingDefaults();
const effectiveHeroLogoVisible = req.body.hero_logo_visible !== undefined ? hero_logo_visible : brandingDefaults.hero_logo_visible;
const effectiveHeroLogoSize = req.body.hero_logo_size || brandingDefaults.hero_logo_size;
const effectiveHeroLogoPosition = req.body.hero_logo_position || brandingDefaults.hero_logo_position;
// Insert into database
const insertResult = await db('events').insert({
slug,
@@ -422,13 +475,14 @@ router.post('/', adminAuth, requirePermission('events.create'), [
watermark_text,
require_password: formatBoolean(requirePassword),
css_template_id: css_template_id || null,
hero_logo_visible: formatBoolean(hero_logo_visible !== undefined ? hero_logo_visible : true),
hero_logo_size: hero_logo_size || 'medium',
hero_logo_position: hero_logo_position || 'top',
hero_logo_visible: formatBoolean(effectiveHeroLogoVisible),
hero_logo_size: effectiveHeroLogoSize,
hero_logo_position: effectiveHeroLogoPosition,
header_style: effectiveHeaderStyle || 'standard',
hero_divider_style: effectiveDividerStyle || 'wave',
hero_image_anchor: hero_image_anchor || 'center',
photo_cap: photo_cap || null,
is_draft: formatBoolean(parseBooleanInput(is_draft, true)),
// Client access (#172)
client_access_enabled: formatBoolean(client_access_enabled),
...(client_access_enabled && client_password ? {
@@ -464,10 +518,11 @@ router.post('/', adminAuth, requirePermission('events.create'), [
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
// Queue creation email (only if there is a recipient)
// Queue creation email (only if there is a recipient and event is not a draft)
// Language detection is handled by email processor
const isDraft = parseBooleanInput(is_draft, true);
if (customerEmail) {
if (customerEmail && !isDraft) {
// Build email data with optional client access info
const emailData = {
customer_name: customerName,
@@ -509,6 +564,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
customer_email: customerEmail,
require_password: requirePassword,
photo_cap: photo_cap || null,
is_draft: isDraft,
share_link: shareUrl,
expires_at: expires_at ? expires_at.toISOString() : null,
created_at: new Date().toISOString()
@@ -556,6 +612,8 @@ router.get('/', adminAuth, requirePermission('events.view'), async (req, res) =>
query = query.where('is_archived', formatBoolean(true));
} else if (status === 'inactive') {
query = query.where('is_active', formatBoolean(false)).where('is_archived', formatBoolean(false));
} else if (status === 'draft') {
query = query.where('is_draft', formatBoolean(true));
} else if (status === 'expiring') {
const sevenDaysFromNow = new Date();
sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7);
@@ -680,6 +738,65 @@ router.get('/:id', adminAuth, requirePermission('events.view'), async (req, res)
}
});
// Publish a draft event (set is_draft=false and queue creation email)
router.post('/:id/publish', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
const event = await db('events').where('id', id).first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
if (!parseBooleanInput(event.is_draft, false)) {
return res.status(400).json({ error: 'Event is already published' });
}
// Set is_draft to false
await db('events').where('id', id).update({ is_draft: formatBoolean(false) });
// Queue creation email
const customerEmail = event.customer_email || event.host_email;
const customerName = event.customer_name || event.host_name;
if (customerEmail) {
const frontendBase = await getFrontendBaseUrl();
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
const emailData = {
customer_name: customerName,
customer_email: customerEmail,
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
event_name: event.event_name,
event_date: event.event_date,
gallery_link: shareUrl || `${frontendBase}/gallery/${event.slug}`,
gallery_password: parseBooleanInput(event.require_password, true) ? '(set at creation)' : 'No password required',
expiry_date: event.expires_at ? new Date(event.expires_at).toISOString() : null,
welcome_message: event.welcome_message || ''
};
await db('email_queue').insert({
event_id: id,
recipient_email: customerEmail,
email_type: 'gallery_created',
email_data: JSON.stringify(emailData),
status: 'pending',
created_at: new Date()
});
}
await logActivity('event_published',
{ event_name: event.event_name },
id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ message: 'Event published successfully', is_draft: false });
} catch (error) {
logger.error('Error publishing event:', { error: error.message });
res.status(500).json({ error: 'Failed to publish event' });
}
});
// Update event
router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
body('event_name').optional().trim().notEmpty(),
+10 -4
View File
@@ -6,7 +6,7 @@ const path = require('path');
const router = express.Router();
const watermarkService = require('../services/watermarkService');
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
const { verifyGalleryAccess } = require('../middleware/gallery');
const { verifyGalleryAccess, isAdminPreview } = require('../middleware/gallery');
const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger');
const { resolvePhotoFilePath } = require('../services/photoResolver');
@@ -74,7 +74,7 @@ router.get('/:slug/verify-token/:token', handleAsync(async (req, res) => {
const { slug, token } = req.params;
const event = await db('events')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false), is_draft: formatBoolean(false) })
.select('id', 'share_link', 'share_token')
.first();
@@ -122,7 +122,8 @@ router.get('/:slug/info', async (req, res) => {
'hero_logo_url',
'header_style',
'hero_divider_style',
'hero_image_anchor'
'hero_image_anchor',
'is_draft'
)
.first();
@@ -138,11 +139,16 @@ router.get('/:slug/info', async (req, res) => {
}
return res.status(404).json({ error: 'Gallery not found' });
}
// Check if event is archived
if (event.is_archived) {
return res.status(404).json({ error: 'Gallery has been archived and is no longer available' });
}
// Check if event is a draft (allow admin preview)
if (event.is_draft && !isAdminPreview(req)) {
return res.status(404).json({ error: 'Gallery is not yet published' });
}
// If token provided, verify it matches the share link
if (token) {
+2 -1
View File
@@ -2,6 +2,7 @@ const nodemailer = require('nodemailer');
const Handlebars = require('handlebars');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
let transporter = null;
let lastConfigHash = null;
@@ -161,7 +162,7 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
const hoverColor = darkenColor(primaryColor, 0.15);
// Build full logo URL - ensure logoUrl is a valid non-empty string
const frontendUrl = (process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/+$/, '');
const frontendUrl = (await getFrontendBaseUrl()) || 'http://localhost:3000';
const logoPath = (typeof logoUrl === 'string' && logoUrl.trim()) ? logoUrl : '/picpeak-logo-transparent.png';
const logoFullUrl = `${frontendUrl}${logoPath.startsWith('/') ? '' : '/'}${logoPath}`;
logger.debug('Email logo URL:', { frontendUrl, logoPath, logoFullUrl });
+4 -2
View File
@@ -1,6 +1,7 @@
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { extractShareToken, isPotentialShareToken, buildSharePath } = require('../utils/shareLinkUtils');
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
const SETTING_KEY = 'general_short_gallery_urls';
const CACHE_TTL_MS = 60_000;
@@ -87,7 +88,7 @@ const buildShareLinkVariants = async ({ slug, shareToken }) => {
const shortEnabled = await isShortGalleryUrlsEnabled();
const sharePath = buildSharePath(slug, shareToken, shortEnabled);
const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, '');
const frontendBase = await getFrontendBaseUrl();
const shareUrl = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
return {
@@ -112,7 +113,8 @@ const getEventShareToken = (event) => {
const ACTIVE_EVENT_FILTER = {
is_active: formatBoolean(true),
is_archived: formatBoolean(false)
is_archived: formatBoolean(false),
is_draft: formatBoolean(false)
};
const resolveShareIdentifier = async (identifier) => {
+27
View File
@@ -0,0 +1,27 @@
const { db } = require('../database/db');
const getFrontendBaseUrl = async () => {
let base = (process.env.FRONTEND_URL || '').trim().replace(/\/$/, '');
if (base) return base;
try {
const setting = await db('app_settings')
.where('setting_key', 'general_site_url')
.select('setting_value')
.first();
if (setting && setting.setting_value) {
let val = setting.setting_value;
if (typeof val === 'string') {
try { val = JSON.parse(val); } catch (_) {}
}
if (typeof val === 'string' && val.trim()) {
base = val.trim().replace(/\/$/, '');
}
}
} catch (_) {}
return base;
};
module.exports = { getFrontendBaseUrl };