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)} + /> +
+ +
+ + +
+
+
+ ); +}; 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. */} + )} @@ -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');