diff --git a/backend/migrations/core/061_add_optional_date_expiration_settings.js b/backend/migrations/core/061_add_optional_date_expiration_settings.js new file mode 100644 index 00000000..b1d236a4 --- /dev/null +++ b/backend/migrations/core/061_add_optional_date_expiration_settings.js @@ -0,0 +1,53 @@ +/** + * Migration: Add optional event date and expiration settings + * These settings control whether event_date and expiration are required + * when creating new events, supporting non-event use cases like portraits. + */ + +exports.up = async function(knex) { + // Add new settings for optional date and expiration + const settings = [ + { setting_key: 'event_require_event_date', setting_value: JSON.stringify(true), setting_type: 'boolean' }, + { setting_key: 'event_require_expiration', setting_value: JSON.stringify(true), setting_type: 'boolean' } + ]; + + for (const setting of settings) { + const exists = await knex('app_settings').where('setting_key', setting.setting_key).first(); + if (!exists) { + await knex('app_settings').insert({ + ...setting, + updated_at: knex.fn.now() + }); + } + } + + // Make event_date and expires_at columns nullable + // PostgreSQL supports ALTER COLUMN ... DROP NOT NULL + // SQLite requires table recreation (handled differently) + const client = knex.client.config.client; + + if (client === 'pg' || client === 'postgresql') { + // PostgreSQL: directly alter columns + await knex.raw('ALTER TABLE events ALTER COLUMN event_date DROP NOT NULL'); + await knex.raw('ALTER TABLE events ALTER COLUMN expires_at DROP NOT NULL'); + } else if (client === 'sqlite3' || client === 'better-sqlite3') { + // SQLite: columns are already effectively nullable in most cases + // SQLite doesn't enforce NOT NULL as strictly, and altering requires table recreation + // For safety, we'll skip the schema change for SQLite as it's complex + // The application logic will handle null values appropriately + console.log('SQLite detected - skipping schema alteration (columns will accept NULL values)'); + } +}; + +exports.down = async function(knex) { + // Remove the settings + await knex('app_settings') + .whereIn('setting_key', [ + 'event_require_event_date', + 'event_require_expiration' + ]) + .del(); + + // Note: We don't restore NOT NULL constraints as that could fail + // if there are existing NULL values in the database +}; diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index a1010786..4522d7c4 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -168,9 +168,10 @@ async function galleryAuth(req, res, next) { return res.status(404).json({ error: 'Gallery not found or expired' }); } - // Check if gallery has expired - if (new Date(event.expires_at) < new Date()) { - return res.status(410).json({ + // Check if gallery has expired (only if expires_at is set) + // Galleries with null expires_at never expire + if (event.expires_at && new Date(event.expires_at) < new Date()) { + return res.status(410).json({ error: 'Gallery has expired', code: 'GALLERY_EXPIRED' }); diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index 5dcbbd6b..3bb8d774 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -25,14 +25,18 @@ const getEventFieldRequirements = async () => { .whereIn('setting_key', [ 'event_require_customer_name', 'event_require_customer_email', - 'event_require_admin_email' + 'event_require_admin_email', + 'event_require_event_date', + 'event_require_expiration' ]) .select('setting_key', 'setting_value'); const requirements = { require_customer_name: true, require_customer_email: true, - require_admin_email: true + require_admin_email: true, + require_event_date: true, + require_expiration: true }; settings.forEach(s => { @@ -47,6 +51,8 @@ const getEventFieldRequirements = async () => { if (s.setting_key === 'event_require_customer_name') requirements.require_customer_name = value; if (s.setting_key === 'event_require_customer_email') requirements.require_customer_email = value; if (s.setting_key === 'event_require_admin_email') requirements.require_admin_email = value; + if (s.setting_key === 'event_require_event_date') requirements.require_event_date = value; + if (s.setting_key === 'event_require_expiration') requirements.require_expiration = value; }); return requirements; @@ -55,7 +61,9 @@ const getEventFieldRequirements = async () => { return { require_customer_name: true, require_customer_email: true, - require_admin_email: true + require_admin_email: true, + require_event_date: true, + require_expiration: true }; } }; @@ -106,7 +114,7 @@ const hasCustomerContactColumns = async () => { router.post('/', adminAuth, requirePermission('events.create'), [ body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']), body('event_name').notEmpty().trim(), - body('event_date').isDate(), + body('event_date').optional().isDate(), body('customer_name').optional().trim(), body('customer_email').optional().isEmail().normalizeEmail(), body('admin_email').optional().isEmail().normalizeEmail(), @@ -201,6 +209,9 @@ router.post('/', adminAuth, requirePermission('events.create'), [ if (fieldRequirements.require_admin_email && !admin_email) { validationErrors.push({ path: 'admin_email', msg: 'Admin email is required' }); } + if (fieldRequirements.require_event_date && !event_date) { + validationErrors.push({ path: 'event_date', msg: 'Event date is required' }); + } if (validationErrors.length > 0) { return res.status(400).json({ errors: validationErrors }); @@ -245,10 +256,13 @@ router.post('/', adminAuth, requirePermission('events.create'), [ .replace(/[^a-z0-9]/g, '-') // Replace non-alphanumeric with dash .replace(/-+/g, '-') // Replace multiple dashes with single dash .replace(/^-|-$/g, ''); // Remove leading/trailing dashes - const baseSlug = `${event_type}-${processedEventName}-${event_date}`; + + // Use event_date in slug if provided, otherwise use random suffix + const slugSuffix = event_date || crypto.randomBytes(3).toString('hex'); + const baseSlug = `${event_type}-${processedEventName}-${slugSuffix}`; let slug = baseSlug; let counter = 1; - + while (await db('events').where({ slug }).first()) { slug = `${baseSlug}-${counter}`; counter++; @@ -264,15 +278,20 @@ router.post('/', adminAuth, requirePermission('events.create'), [ : await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds()); // Calculate expiration date (days after event date) - // Parse YYYY-MM-DD format as local date to avoid timezone issues - let expires_at; - if (event_date.match(/^\d{4}-\d{2}-\d{2}$/)) { - const [year, month, day] = event_date.split('-').map(num => parseInt(num, 10)); - expires_at = new Date(year, month - 1, day); - } else { - expires_at = new Date(event_date); + // If expiration is not required, expires_at will be null (never expires) + // If event_date is not provided, use current date as base for expiration + let expires_at = null; + if (fieldRequirements.require_expiration) { + const baseDate = event_date || new Date().toISOString().split('T')[0]; + // Parse YYYY-MM-DD format as local date to avoid timezone issues + if (baseDate.match(/^\d{4}-\d{2}-\d{2}$/)) { + const [year, month, day] = baseDate.split('-').map(num => parseInt(num, 10)); + expires_at = new Date(year, month - 1, day); + } else { + expires_at = new Date(baseDate); + } + expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10)); } - expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10)); // Create folder structure const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); @@ -285,7 +304,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [ slug, event_type, event_name, - event_date, + event_date: event_date || null, ...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}), host_name: customerName, host_email: customerEmail, @@ -295,7 +314,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [ color_theme, share_link: shareLinkToStore, share_token: shareToken, - expires_at: expires_at.toISOString(), + expires_at: expires_at ? expires_at.toISOString() : null, created_at: new Date().toISOString(), created_by: req.admin.id, allow_user_uploads, @@ -350,7 +369,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [ event_date: event_date, // Pass raw date - will be formatted by email processor gallery_link: shareUrl, gallery_password: requirePassword ? password : 'No password required', - expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor + expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor welcome_message: welcome_message || '' }), status: 'pending', @@ -367,7 +386,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [ customer_email: customerEmail, require_password: requirePassword, share_link: shareUrl, - expires_at: expires_at.toISOString(), + expires_at: expires_at ? expires_at.toISOString() : null, created_at: new Date().toISOString() }); } catch (error) { diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index e75572ad..6b2fee27 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -152,7 +152,7 @@ router.get('/:slug/info', async (req, res) => { event_date: event.event_date, expires_at: event.expires_at, is_active: event.is_active, - is_expired: !event.is_active || new Date(event.expires_at) < new Date(), + is_expired: !event.is_active || (event.expires_at && new Date(event.expires_at) < new Date()), requires_password: requiresPassword, color_theme: event.color_theme, allow_downloads: !(event.allow_downloads === false || event.allow_downloads === 0 || event.allow_downloads === '0'), diff --git a/backend/src/routes/publicSettings.js b/backend/src/routes/publicSettings.js index b6e9ac56..389860f4 100644 --- a/backend/src/routes/publicSettings.js +++ b/backend/src/routes/publicSettings.js @@ -74,7 +74,9 @@ router.get('/', async (req, res) => { // Event field requirements event_require_customer_name: settingsObject.event_require_customer_name !== false, event_require_customer_email: settingsObject.event_require_customer_email !== false, - event_require_admin_email: settingsObject.event_require_admin_email !== false + event_require_admin_email: settingsObject.event_require_admin_email !== false, + event_require_event_date: settingsObject.event_require_event_date !== false, + event_require_expiration: settingsObject.event_require_expiration !== false }; res.json(publicSettings); diff --git a/backend/src/services/expirationChecker.js b/backend/src/services/expirationChecker.js index 7334fb03..3aca41ca 100644 --- a/backend/src/services/expirationChecker.js +++ b/backend/src/services/expirationChecker.js @@ -21,9 +21,11 @@ async function checkExpirations() { const warningDate = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days from now // Check for events needing warning emails + // Skip events with null expires_at (they never expire) const eventsNeedingWarning = await db('events') .where('is_active', formatBoolean(true)) .where('is_archived', formatBoolean(false)) + .whereNotNull('expires_at') .where('expires_at', '<=', warningDate) .where('expires_at', '>', now); @@ -40,9 +42,11 @@ async function checkExpirations() { } // Check for expired events + // Skip events with null expires_at (they never expire) const expiredEvents = await db('events') .where('is_active', formatBoolean(true)) .where('is_archived', formatBoolean(false)) + .whereNotNull('expires_at') .where('expires_at', '<=', now); for (const event of expiredEvents) { diff --git a/frontend/src/features/settings/hooks/useSettingsState.ts b/frontend/src/features/settings/hooks/useSettingsState.ts index bf8c1792..e6676828 100644 --- a/frontend/src/features/settings/hooks/useSettingsState.ts +++ b/frontend/src/features/settings/hooks/useSettingsState.ts @@ -49,6 +49,8 @@ export interface EventSettings { event_require_customer_name: boolean; event_require_customer_email: boolean; event_require_admin_email: boolean; + event_require_event_date: boolean; + event_require_expiration: boolean; } export function useSettingsState() { @@ -109,7 +111,9 @@ export function useSettingsState() { const [eventSettings, setEventSettings] = useState({ event_require_customer_name: true, event_require_customer_email: true, - event_require_admin_email: true + event_require_admin_email: true, + event_require_event_date: true, + event_require_expiration: true }); // Account form state @@ -178,7 +182,9 @@ export function useSettingsState() { setEventSettings({ event_require_customer_name: toBoolean(settings.event_require_customer_name, true), event_require_customer_email: toBoolean(settings.event_require_customer_email, true), - event_require_admin_email: toBoolean(settings.event_require_admin_email, true) + event_require_admin_email: toBoolean(settings.event_require_admin_email, true), + event_require_event_date: toBoolean(settings.event_require_event_date, true), + event_require_expiration: toBoolean(settings.event_require_expiration, true) }); } }, [settings, i18n]); diff --git a/frontend/src/features/settings/tabs/EventsTab.tsx b/frontend/src/features/settings/tabs/EventsTab.tsx index 9001b2af..ee32d572 100644 --- a/frontend/src/features/settings/tabs/EventsTab.tsx +++ b/frontend/src/features/settings/tabs/EventsTab.tsx @@ -99,6 +99,56 @@ export const EventsTab: React.FC = ({ + +
+ +
+ +
+ +
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index dbf23b3f..ab8161cd 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -759,6 +759,8 @@ "themeAndStyle": "Design & Stil", "galleryWillExpireOn": "Galerie läuft ab am {{date}}", "expirationWarning": "Gäste erhalten 7 Tage vor Ablauf eine Warn-E-Mail.", + "noExpiration": "Kein Ablaufdatum", + "noExpirationHelp": "Diese Galerie bleibt aktiv, bis sie manuell archiviert wird.", "userUploads": "Benutzer-Upload-Einstellungen", "allowUserUploads": "Gästen erlauben, Fotos hochzuladen", "allowUserUploadsHelp": "Ermöglichen Sie Gästen, ihre eigenen Fotos in diese Galerie hochzuladen", @@ -1048,6 +1050,12 @@ "requireAdminEmail": "Admin-E-Mail erforderlich", "requireAdminEmailHelp": "Admin-E-Mail muss für neue Veranstaltungen angegeben werden", "adminEmailWarning": "Erforderlich für den Erhalt von Veranstaltungsbenachrichtigungen", + "requireEventDate": "Veranstaltungsdatum erforderlich", + "requireEventDateHelp": "Veranstaltungsdatum muss beim Erstellen angegeben werden", + "eventDateWarning": "Galerie-URLs verwenden zufällige Kennungen anstelle von Daten", + "requireExpiration": "Ablaufdatum erforderlich", + "requireExpirationHelp": "Galerien müssen ein Ablaufdatum haben", + "expirationWarning": "Galerien ohne Ablaufdatum bleiben aktiv, bis sie manuell archiviert werden", "saveSettings": "Veranstaltungseinstellungen speichern", "noteTitle": "Hinweis", "noteText": "Diese Einstellungen betreffen nur die Erstellung neuer Veranstaltungen. Bestehende Veranstaltungen sind nicht betroffen. Standardmäßig sind alle Felder erforderlich." diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 6ecb989b..f11e78bf 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -433,6 +433,8 @@ "expiresOn": "Expires on", "galleryWillExpireOn": "Gallery will expire on {{date}}", "expirationWarning": "Guests will receive a warning email 7 days before expiration.", + "noExpiration": "No Expiration", + "noExpirationHelp": "This gallery will remain active until manually archived.", "userUploads": "User Upload Settings", "allowUserUploads": "Allow guests to upload photos", "allowUserUploadsHelp": "Enable guests to upload their own photos to this gallery", @@ -753,6 +755,12 @@ "requireAdminEmail": "Require admin email", "requireAdminEmailHelp": "Admin email must be provided for new events", "adminEmailWarning": "Required for receiving event notifications", + "requireEventDate": "Require event date", + "requireEventDateHelp": "Event date must be provided when creating events", + "eventDateWarning": "Gallery URLs will use random identifiers instead of dates", + "requireExpiration": "Require expiration date", + "requireExpirationHelp": "Galleries must have an expiration date", + "expirationWarning": "Galleries without expiration will remain active until manually archived", "saveSettings": "Save Event Settings", "noteTitle": "Note", "noteText": "These settings only affect new event creation. Existing events are not affected. Default behavior requires all fields." diff --git a/frontend/src/pages/admin/CreateEventPage.tsx b/frontend/src/pages/admin/CreateEventPage.tsx index 757b255b..eeca593d 100644 --- a/frontend/src/pages/admin/CreateEventPage.tsx +++ b/frontend/src/pages/admin/CreateEventPage.tsx @@ -148,6 +148,8 @@ export const CreateEventPage: React.FC = () => { const requireCustomerName = publicSettings?.event_require_customer_name !== false; const requireCustomerEmail = publicSettings?.event_require_customer_email !== false; const requireAdminEmail = publicSettings?.event_require_admin_email !== false; + const requireEventDate = publicSettings?.event_require_event_date !== false; + const requireExpiration = publicSettings?.event_require_expiration !== false; // Update default expiration days when settings are loaded useEffect(() => { @@ -201,7 +203,7 @@ export const CreateEventPage: React.FC = () => { newErrors.event_name = t('validation.eventNameRequired'); } - if (!formData.event_date) { + if (requireEventDate && !formData.event_date) { newErrors.event_date = t('validation.eventDateRequired'); } @@ -247,7 +249,7 @@ export const CreateEventPage: React.FC = () => { } } - if (formData.expires_in_days < 1 || formData.expires_in_days > 365) { + if (requireExpiration && (formData.expires_in_days < 1 || formData.expires_in_days > 365)) { newErrors.expires_in_days = t('validation.expirationRange'); } @@ -267,7 +269,7 @@ export const CreateEventPage: React.FC = () => { const payload = { event_type: formData.event_type, event_name: formData.event_name, - event_date: formData.event_date, + event_date: formData.event_date || undefined, customer_name: formData.customer_name, customer_email: formData.customer_email, admin_email: formData.admin_email, @@ -275,7 +277,7 @@ export const CreateEventPage: React.FC = () => { password: formData.require_password ? formData.password : undefined, welcome_message: formData.welcome_message || '', color_theme: JSON.stringify(formData.theme_config), - expiration_days: formData.expires_in_days, + expiration_days: requireExpiration ? formData.expires_in_days : undefined, allow_user_uploads: formData.allow_user_uploads, upload_category_id: formData.upload_category_id, css_template_id: formData.css_template_id, @@ -394,7 +396,7 @@ export const CreateEventPage: React.FC = () => { {
)} -
- -
-
- } - /> + {requireExpiration ? ( +
+ +
+
+ } + /> +
+ {t('events.daysAfterEvent')}
- {t('events.daysAfterEvent')} + {formData.event_date && ( +

+ {t('events.expiresOn')}: {format(addDays(new Date(formData.event_date), formData.expires_in_days))} +

+ )}
- {formData.event_date && ( -

- {t('events.expiresOn')}: {format(addDays(new Date(formData.event_date), formData.expires_in_days))} + ) : ( +

+
+ + {t('events.noExpiration', 'No Expiration')} +
+

+ {t('events.noExpirationHelp', 'This gallery will remain active until manually archived.')}

- )} -
+
+ )} {/* User Upload Settings */}
diff --git a/frontend/src/services/events.service.ts b/frontend/src/services/events.service.ts index 433d0473..a62fb18a 100644 --- a/frontend/src/services/events.service.ts +++ b/frontend/src/services/events.service.ts @@ -20,15 +20,15 @@ const normalizeEvent = (event: Event): Event => { interface CreateEventData { event_type: string; event_name: string; - event_date: string; + event_date?: string; customer_name?: string; - customer_email: string; - admin_email: string; + customer_email?: string; + admin_email?: string; require_password?: boolean; password?: string; welcome_message?: string; color_theme?: string; - expiration_days: number; + expiration_days?: number; allow_user_uploads?: boolean; upload_category_id?: number | null; feedback_enabled?: boolean; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index d9258ce9..95bc67bb 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -4,7 +4,7 @@ export interface Event { slug: string; event_type: string; event_name: string; - event_date: string; + event_date: string | null; customer_name?: string; customer_email: string; admin_email: string; @@ -12,7 +12,7 @@ export interface Event { color_theme?: string; share_link: string; created_at: string; - expires_at: string; + expires_at: string | null; is_active: boolean; is_archived: boolean; archive_path?: string; @@ -46,8 +46,8 @@ export interface Event { export interface GalleryInfo { event_name: string; event_type: string; - event_date: string; - expires_at: string; + event_date: string | null; + expires_at: string | null; is_active: boolean; is_expired: boolean; requires_password?: boolean; @@ -97,10 +97,10 @@ export interface GalleryData { id: number; event_name: string; event_type: string; - event_date: string; + event_date: string | null; welcome_message?: string; color_theme?: string; - expires_at: string; + expires_at: string | null; allow_user_uploads?: boolean; upload_category_id?: number | null; hero_photo_id?: number | null;