From e985d25207671cbfefcdda9775a96eadf2fe0698 Mon Sep 17 00:00:00 2001
From: Paul Nothaft
Date: Wed, 17 Jun 2026 23:16:12 +0200
Subject: [PATCH] feat(events): duplicate-gallery action (#626)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Daniel asked for a way to re-use a good gallery configuration without
re-entering every setting. Two of his three suggested workflows are
covered by this PR; the third (per-event-type behaviour defaults) is
partially shipped already via event_types.theme_preset + theme_config
and is left as a follow-up if the duplicate workflow doesn't cover it.
Backend — POST /admin/events/:id/duplicate. Validates a new event_name
(required) + event_date (optional) + customer_name/email (optional);
copies branding (color_theme, css_template_id, header/hero/divider/anchor),
behaviour toggles (allow_downloads, watermark_*, allow_user_uploads,
require_password, etc.), photo_cap, welcome_message, default_photo_sort,
admin_email, and feedback settings + per-event photo categories. Mints a
fresh slug + share_token + random-placeholder password_hash (admin sets
the real one via the publish dialog shipped in #627). Recomputes
expires_at = new_event_date + (source.expires_at - source.event_date) so
the duplicate keeps the same active window; defaults to 30 days if either
source field was null. is_draft is always true.
Deliberately NOT carried over: photos, hero_photo_id, client_access
secrets, og_image_share opt-in, customer_phone, sent_at flags, archive
state, customer-account assignments.
Frontend — new DuplicateEventDialog (matches the PublishGalleryDialog
pattern), wired into the Actions card on EventDetailsPage. Visible in
both draft and live mode since admins typically duplicate from a
published gallery. On success the page navigates to the new draft so the
admin can finish customising + publish.
I18n: EN + DE entries for the dialog + button label. Backend logs an
event_duplicated activity with the source event id/name so the trail is
auditable.
Frontend service: eventsService.duplicateEvent(eventId, data).
---
backend/src/routes/adminEvents.js | 187 ++++++++++++++++++
.../components/admin/DuplicateEventDialog.tsx | 141 +++++++++++++
frontend/src/components/admin/index.ts | 1 +
frontend/src/i18n/locales/de.json | 16 ++
frontend/src/i18n/locales/en.json | 16 ++
frontend/src/pages/admin/EventDetailsPage.tsx | 50 ++++-
frontend/src/services/events.service.ts | 16 ++
7 files changed, 426 insertions(+), 1 deletion(-)
create mode 100644 frontend/src/components/admin/DuplicateEventDialog.tsx
diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js
index 448cf552..3054ed80 100644
--- a/backend/src/routes/adminEvents.js
+++ b/backend/src/routes/adminEvents.js
@@ -1176,6 +1176,193 @@ router.post('/:id/publish', adminAuth, requirePermission('events.edit'), require
}
});
+// Duplicate an event (#626). Creates a new DRAFT gallery that inherits the
+// source event's branding, behaviour, hero/header, feedback, and category
+// configuration — admin then fills in customer + publishes via the publish
+// dialog (#627), where the password is set. Photos, hero photo selection,
+// client-access secrets, customer assignments, archive/sent state are NOT
+// carried over.
+router.post('/:id/duplicate', adminAuth, requirePermission('events.create'), requireEventOwnership, [
+ body('event_name').trim().notEmpty().withMessage('Event name is required'),
+ body('event_date').optional({ values: 'falsy' }).isDate(),
+ body('customer_name').optional().trim(),
+ body('customer_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL),
+], async (req, res) => {
+ try {
+ const errors = validationResult(req);
+ if (!errors.isEmpty()) {
+ return res.status(400).json({ errors: errors.array() });
+ }
+
+ const { id } = req.params;
+ const source = await db('events').where('id', id).first();
+ if (!source) {
+ return res.status(404).json({ error: 'Source event not found' });
+ }
+
+ const { event_name, event_date, customer_name, customer_email } = req.body;
+
+ // Generate a fresh unique slug using the same shape as the create path.
+ const slugify = require('../utils/slug').slugify;
+ const processedEventName = slugify(event_name);
+ const slugSuffix = event_date || crypto.randomBytes(3).toString('hex');
+ const baseSlug = `${source.event_type}-${processedEventName}-${slugSuffix}`;
+ let slug = baseSlug;
+ let counter = 1;
+ // eslint-disable-next-line no-await-in-loop
+ while (await db('events').where({ slug }).first()) {
+ slug = `${baseSlug}-${counter}`;
+ counter += 1;
+ }
+
+ // Recompute expires_at: preserve the source's expiration window (delta
+ // between source.expires_at and source.event_date) so the duplicate keeps
+ // the same "active for N days" feel. Falls back to 30 days if source had
+ // no expiration set.
+ let newExpiresAt = null;
+ if (event_date) {
+ let expirationDays = 30;
+ if (source.expires_at && source.event_date) {
+ const days = Math.round(
+ (new Date(source.expires_at).getTime() - new Date(source.event_date).getTime())
+ / (24 * 60 * 60 * 1000),
+ );
+ if (days > 0) expirationDays = days;
+ }
+ const [year, month, day] = event_date.split('-').map((s) => parseInt(s, 10));
+ const baseDate = new Date(year, month - 1, day);
+ baseDate.setDate(baseDate.getDate() + expirationDays);
+ newExpiresAt = baseDate;
+ }
+
+ const shareToken = crypto.randomBytes(16).toString('hex');
+ const { shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
+
+ // Random-placeholder password hash. When the admin publishes via the
+ // PublishGalleryDialog (#627), the dialog re-hashes whatever they type and
+ // overwrites this. Pattern matches the create path at line ~606.
+ const password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
+
+ // Create the storage folder structure (same as create path).
+ const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
+ const eventPath = path.join(storagePath, 'events/active', slug);
+ await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true });
+ await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
+
+ const customerColumnsAvailable = await hasCustomerContactColumns();
+ const calendarColumnsExist = await hasColumnCached('events', 'is_full_day');
+
+ // Build the insert row. Copy behaviour + branding fields from source;
+ // leave per-gallery secrets / state / photos blank.
+ const insertResult = await db('events').insert({
+ slug,
+ event_type: source.event_type,
+ event_name,
+ event_date: event_date || null,
+ ...(calendarColumnsExist ? {
+ event_time_start: source.event_time_start,
+ event_time_end: source.event_time_end,
+ is_full_day: source.is_full_day,
+ } : {}),
+ ...(customerColumnsAvailable ? {
+ customer_name: customer_name || null,
+ customer_email: customer_email || null,
+ } : {}),
+ host_name: customer_name || null,
+ host_email: customer_email || null,
+ admin_email: source.admin_email || null,
+ password_hash,
+ welcome_message: source.welcome_message || '',
+ color_theme: source.color_theme,
+ share_link: shareLinkToStore,
+ share_token: shareToken,
+ expires_at: newExpiresAt ? newExpiresAt.toISOString() : null,
+ created_at: new Date().toISOString(),
+ created_by: req.admin.id,
+ allow_user_uploads: source.allow_user_uploads,
+ upload_category_id: source.upload_category_id,
+ allow_downloads: source.allow_downloads,
+ disable_right_click: source.disable_right_click,
+ enable_devtools_protection: source.enable_devtools_protection,
+ watermark_downloads: source.watermark_downloads,
+ watermark_text: source.watermark_text,
+ allow_presigned_download: source.allow_presigned_download,
+ require_password: source.require_password,
+ css_template_id: source.css_template_id || null,
+ hero_logo_visible: source.hero_logo_visible,
+ hero_logo_size: source.hero_logo_size,
+ hero_logo_position: source.hero_logo_position,
+ header_style: source.header_style || 'standard',
+ hero_divider_style: source.hero_divider_style || 'wave',
+ hero_image_anchor: source.hero_image_anchor || 'center',
+ photo_cap: source.photo_cap || null,
+ is_draft: formatBoolean(true),
+ default_photo_sort: source.default_photo_sort || 'upload_date_desc',
+ // Client-access secrets and the OG-share opt-in deliberately do NOT
+ // carry over — admin re-decides per gallery.
+ client_access_enabled: formatBoolean(false),
+ og_image_share_enabled: formatBoolean(false),
+ }).returning('id');
+
+ const newEventId = insertResult[0]?.id || insertResult[0];
+
+ // Copy event_feedback_settings if the source had a row (only present when
+ // feedback_enabled was true on the source event).
+ const sourceFeedback = await db('event_feedback_settings').where({ event_id: id }).first();
+ if (sourceFeedback) {
+ await db('event_feedback_settings').insert({
+ event_id: newEventId,
+ feedback_enabled: sourceFeedback.feedback_enabled,
+ allow_ratings: sourceFeedback.allow_ratings,
+ allow_likes: sourceFeedback.allow_likes,
+ allow_comments: sourceFeedback.allow_comments,
+ allow_favorites: sourceFeedback.allow_favorites,
+ require_name_email: sourceFeedback.require_name_email,
+ moderate_comments: sourceFeedback.moderate_comments,
+ show_feedback_to_guests: sourceFeedback.show_feedback_to_guests,
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ });
+ }
+
+ // Copy per-event photo categories (global categories are not duplicated —
+ // they apply to every event already). Mapping by name; photo_categories
+ // has no foreign key into photos here so we just clone the rows.
+ if (await db.schema.hasTable('photo_categories')) {
+ const sourceCategories = await db('photo_categories')
+ .where({ event_id: id })
+ .where(function () { this.whereNull('is_global').orWhere('is_global', formatBoolean(false)); })
+ .select('name', 'slug', 'is_global');
+ if (sourceCategories.length > 0) {
+ await db('photo_categories').insert(
+ sourceCategories.map((c) => ({
+ event_id: newEventId,
+ name: c.name,
+ slug: c.slug,
+ is_global: formatBoolean(false),
+ })),
+ );
+ }
+ }
+
+ await logActivity('event_duplicated',
+ { source_event_id: parseInt(id, 10), source_event_name: source.event_name },
+ newEventId,
+ { type: 'admin', id: req.admin.id, name: req.admin.username },
+ );
+
+ res.json({
+ message: 'Event duplicated successfully',
+ id: newEventId,
+ slug,
+ is_draft: true,
+ });
+ } catch (error) {
+ logger.error('Error duplicating event:', { error: error.message });
+ res.status(500).json({ error: 'Failed to duplicate event' });
+ }
+});
+
// Update event
router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
body('event_name').optional().trim().notEmpty(),
diff --git a/frontend/src/components/admin/DuplicateEventDialog.tsx b/frontend/src/components/admin/DuplicateEventDialog.tsx
new file mode 100644
index 00000000..42756617
--- /dev/null
+++ b/frontend/src/components/admin/DuplicateEventDialog.tsx
@@ -0,0 +1,141 @@
+import React, { useState } from 'react';
+import { X, Copy } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { Button, Card, Input, LocalizedDateInput } from '../common';
+
+interface DuplicateEventDialogProps {
+ sourceEventName: string;
+ isDuplicating: boolean;
+ onConfirm: (data: {
+ event_name: string;
+ event_date?: string;
+ customer_name?: string;
+ customer_email?: string;
+ }) => void;
+ onClose: () => void;
+}
+
+/**
+ * "Duplicate gallery" dialog (#626) — admin types a fresh event name + date
+ * (and optionally a new customer) and the backend clones the source event's
+ * branding / behaviour / feedback / categories into a new draft. Photos,
+ * the password, the share token and client-access secrets do NOT carry over —
+ * those are set fresh on the duplicate. The new event opens in draft mode so
+ * the admin can finish customising before publishing via the publish dialog
+ * (#627).
+ */
+export const DuplicateEventDialog: React.FC = ({
+ sourceEventName,
+ isDuplicating,
+ onConfirm,
+ onClose,
+}) => {
+ const { t } = useTranslation();
+ const [eventName, setEventName] = useState('');
+ const [eventDate, setEventDate] = useState('');
+ const [customerName, setCustomerName] = useState('');
+ const [customerEmail, setCustomerEmail] = useState('');
+ const [error, setError] = useState(undefined);
+
+ const handleSubmit = () => {
+ if (!eventName.trim()) {
+ setError(t('events.duplicateDialog.errorNameRequired', 'Event name is required.'));
+ return;
+ }
+ setError(undefined);
+ onConfirm({
+ event_name: eventName.trim(),
+ event_date: eventDate || undefined,
+ customer_name: customerName.trim() || undefined,
+ customer_email: customerEmail.trim() || undefined,
+ });
+ };
+
+ return (
+
+
+
+
+ {t('events.duplicateDialog.title', 'Duplicate gallery')}
+
+
+
+
+
+
+
+ {t('events.duplicateDialog.description', {
+ sourceEventName,
+ defaultValue:
+ 'Creates a new draft gallery that inherits the branding, behaviour, feedback, and category configuration from "{{sourceEventName}}". Photos, password, and share tokens are NOT carried over.',
+ })}
+
+
+
+ {
+ setEventName(e.target.value);
+ if (error) setError(undefined);
+ }}
+ error={error}
+ />
+
+
+
+ setCustomerName(e.target.value)}
+ />
+
+ setCustomerEmail(e.target.value)}
+ />
+
+
+
+
+ {t('common.cancel', 'Cancel')}
+
+ }
+ className="flex-1"
+ >
+ {t('events.duplicateDialog.confirm', 'Create duplicate')}
+
+
+
+
+ );
+};
diff --git a/frontend/src/components/admin/index.ts b/frontend/src/components/admin/index.ts
index d274aab2..05911bac 100644
--- a/frontend/src/components/admin/index.ts
+++ b/frontend/src/components/admin/index.ts
@@ -18,6 +18,7 @@ export { AdminPhotoViewer } from './AdminPhotoViewer';
export { PhotoFilters } from './PhotoFilters';
export { PasswordResetModal } from './PasswordResetModal';
export { PublishGalleryDialog } from './PublishGalleryDialog';
+export { DuplicateEventDialog } from './DuplicateEventDialog';
export { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
export { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo';
export { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index eb1fda5d..23cc1c67 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -1027,6 +1027,22 @@
"passwordHelp": "Gib das bei der Erstellung gesetzte Passwort erneut ein (oder wähle ein neues). Die E-Mail enthält genau diesen Text; das Backend hasht es erneut, sodass die Galerie-Anmeldung weiterhin funktioniert.",
"errorMinLength": "Das Passwort muss mindestens 6 Zeichen lang sein."
},
+ "duplicateEvent": "Galerie duplizieren",
+ "duplicateDialog": {
+ "title": "Galerie duplizieren",
+ "description": "Erstellt eine neue Entwurfsgalerie, die Branding, Verhalten, Feedback und Kategorien aus \"{{sourceEventName}}\" übernimmt. Fotos, Passwort und Share-Tokens werden NICHT übernommen.",
+ "eventNameLabel": "Neuer Veranstaltungsname *",
+ "eventNamePlaceholder": "z. B. Hochzeit Müller 2026",
+ "eventDateLabel": "Veranstaltungsdatum",
+ "eventDateHelp": "Leer lassen, um eine zufällige Endung in der Galerie-URL zu verwenden. Das Ablaufdatum wird aus diesem Datum zuzüglich des Ablauffensters der Quellgalerie berechnet.",
+ "customerNameLabel": "Kundenname",
+ "customerNamePlaceholder": "Optional – kann später ausgefüllt werden",
+ "customerEmailLabel": "Kunden-E-Mail",
+ "customerEmailPlaceholder": "Optional",
+ "confirm": "Duplikat erstellen",
+ "errorNameRequired": "Veranstaltungsname ist erforderlich.",
+ "successToast": "Galerie dupliziert."
+ },
"draftBanner": "Diese Galerie befindet sich im Entwurfsmodus. Laden Sie Ihre Fotos hoch und veröffentlichen Sie, wenn Sie bereit sind.",
"subtitle": "Verwalten Sie Ihre Fotogalerien und Veranstaltungen",
"failedToLoadEvents": "Veranstaltungen konnten nicht geladen werden",
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 6151d286..9fc812af 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -574,6 +574,22 @@
"passwordHelp": "Re-type the password set at creation (or pick a new one). The email includes this exact text; the backend re-hashes it so the gallery login still works.",
"errorMinLength": "Password must be at least 6 characters long."
},
+ "duplicateEvent": "Duplicate gallery",
+ "duplicateDialog": {
+ "title": "Duplicate gallery",
+ "description": "Creates a new draft gallery that inherits the branding, behaviour, feedback, and category configuration from \"{{sourceEventName}}\". Photos, password, and share tokens are NOT carried over.",
+ "eventNameLabel": "New event name *",
+ "eventNamePlaceholder": "e.g. Müller Wedding 2026",
+ "eventDateLabel": "Event date",
+ "eventDateHelp": "Leave blank to use a random suffix in the gallery URL. Expiration is recomputed from this date plus the source gallery's expiration window.",
+ "customerNameLabel": "Customer name",
+ "customerNamePlaceholder": "Optional — fill in later if unknown",
+ "customerEmailLabel": "Customer email",
+ "customerEmailPlaceholder": "Optional",
+ "confirm": "Create duplicate",
+ "errorNameRequired": "Event name is required.",
+ "successToast": "Gallery duplicated."
+ },
"draftBanner": "This gallery is in draft mode. Upload your photos, then publish when ready.",
"subtitle": "Manage your photo galleries and events",
"failedToLoadEvents": "Failed to load events",
diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx
index 1ddf4332..a413ecdb 100644
--- a/frontend/src/pages/admin/EventDetailsPage.tsx
+++ b/frontend/src/pages/admin/EventDetailsPage.tsx
@@ -59,7 +59,7 @@ import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button, Input, Card, Loading, MarkdownContent, LocalizedDateInput } from '../../components/common';
-import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, PublishGalleryDialog, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu, AdminGuestsList } from '../../components/admin';
+import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, PublishGalleryDialog, DuplicateEventDialog, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu, AdminGuestsList } from '../../components/admin';
import { CustomerAccountPicker } from '../../components/admin/CustomerAccountPicker';
import { EventReminderOverrideCard } from '../../components/admin/EventReminderOverrideCard';
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
@@ -374,6 +374,7 @@ export const EventDetailsPage: React.FC = () => {
const [showNewPassword, setShowNewPassword] = useState(false);
const [showRenameDialog, setShowRenameDialog] = useState(false);
const [showPublishDialog, setShowPublishDialog] = useState(false);
+ const [showDuplicateDialog, setShowDuplicateDialog] = useState(false);
const [logoUploading, setLogoUploading] = useState(false);
const [currentTheme, setCurrentTheme] = useState(null);
const [currentPresetName, setCurrentPresetName] = useState('default');
@@ -550,6 +551,28 @@ export const EventDetailsPage: React.FC = () => {
},
});
+ // Duplicate mutation (#626). Backend creates a draft inheriting branding +
+ // behaviour + categories from the source; we navigate to the new event so
+ // the admin can finish configuring + publish.
+ const duplicateMutation = useMutation({
+ mutationFn: (data: {
+ event_name: string;
+ event_date?: string;
+ customer_name?: string;
+ customer_email?: string;
+ }) => eventsService.duplicateEvent(parseInt(id!), data),
+ onSuccess: (result) => {
+ queryClient.invalidateQueries({ queryKey: ['admin-events'] });
+ toast.success(t('events.duplicateDialog.successToast', 'Gallery duplicated.'));
+ setShowDuplicateDialog(false);
+ navigate(`/admin/events/${result.id}`);
+ },
+ onError: (err: any) => {
+ const msg = err?.response?.data?.errors?.[0]?.msg || err?.response?.data?.error;
+ toast.error(msg || t('errors.somethingWentWrong'));
+ },
+ });
+
// Extend expiration mutation
const extendMutation = useMutation({
mutationFn: (days: number) => {
@@ -2191,6 +2214,17 @@ export const EventDetailsPage: React.FC = () => {
>
)}
+ {/* Duplicate (#626) — visible in both draft and live mode.
+ Creates a new draft inheriting this gallery's config. */}
+ }
+ onClick={() => setShowDuplicateDialog(true)}
+ isLoading={duplicateMutation.isPending}
+ className="w-full justify-center"
+ >
+ {t('events.duplicateEvent', 'Duplicate gallery')}
+
)}
@@ -2615,6 +2649,20 @@ export const EventDetailsPage: React.FC = () => {
/>
)}
+ {/* Duplicate Event Dialog (#626) — admin types a new event name/date
+ (+ optional customer); backend clones the source gallery's config
+ and we navigate to the new draft. */}
+ {showDuplicateDialog && (
+ duplicateMutation.mutate(data)}
+ onClose={() => {
+ if (!duplicateMutation.isPending) setShowDuplicateDialog(false);
+ }}
+ />
+ )}
+
);
};
diff --git a/frontend/src/services/events.service.ts b/frontend/src/services/events.service.ts
index 27567c6b..b678e6bb 100644
--- a/frontend/src/services/events.service.ts
+++ b/frontend/src/services/events.service.ts
@@ -232,6 +232,22 @@ export const eventsService = {
return response.data;
},
+ // Duplicate an event (#626). Creates a new draft gallery that inherits the
+ // source event's branding + behaviour + feedback + categories. Photos are
+ // NOT carried over. The returned id/slug are the new draft event.
+ async duplicateEvent(
+ eventId: number,
+ data: {
+ event_name: string;
+ event_date?: string;
+ customer_name?: string;
+ customer_email?: string;
+ },
+ ): Promise<{ message: string; id: number; slug: string; is_draft: boolean }> {
+ const response = await api.post(`/admin/events/${eventId}/duplicate`, data);
+ return response.data;
+ },
+
// Get admin preview token (uses existing admin session token)
getPreviewToken(): string | null {
const token = sessionStorage.getItem('admin_token') || localStorage.getItem('admin_token');