From fbd7b67016d58e6d8205597f0e71b2e033fbf29c Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Fri, 2 Jan 2026 10:16:55 +0100 Subject: [PATCH] refactor: Add Photo and Settings service layers Phase 2.2: Photo service layer - Create backend/src/services/photoService.js - Functions: getPhotosForEvent, getPhotoById, getPhotoCount - Functions: updatePhoto, deletePhoto, bulkDeletePhotos - Functions: updateSortOrder, moveToCategory, setHeroPhoto - Support for soft delete and hard delete with file cleanup Phase 2.3: Settings service layer - Create backend/src/services/settingsService.js - Functions: getAllSettings, getSetting, getSettingsByPrefix - Functions: updateSetting, updateSettings, deleteSetting - Functions: getPublicSettings, getBrandingSettings, getEmailSettings - Type-aware setting parsing (boolean, number, json, string) All core service layers are now established for: - Events (CRUD, slug generation, expiration) - Photos (CRUD, categories, sorting, hero) - Settings (typed get/set, prefix queries) Routes can be incrementally migrated to use these services. --- backend/src/services/photoService.js | 253 ++++++++++++++++++++++ backend/src/services/settingsService.js | 267 ++++++++++++++++++++++++ 2 files changed, 520 insertions(+) create mode 100644 backend/src/services/photoService.js create mode 100644 backend/src/services/settingsService.js diff --git a/backend/src/services/photoService.js b/backend/src/services/photoService.js new file mode 100644 index 00000000..7728396b --- /dev/null +++ b/backend/src/services/photoService.js @@ -0,0 +1,253 @@ +/** + * Photo Service Layer + * Handles photo-related business logic + * + * @module services/photoService + */ + +const path = require('path'); +const fs = require('fs').promises; +const { db } = require('../database/db'); +const { formatBoolean } = require('../utils/dbCompat'); + +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage'); + +/** + * Get photos for an event with optional filtering + * @param {number} eventId - Event ID + * @param {Object} options - Filter options + * @returns {Promise} + */ +const getPhotosForEvent = async (eventId, options = {}) => { + const { categoryId, uploadSource, includeHidden = false } = options; + + let query = db('photos') + .where('event_id', eventId) + .orderBy('sort_order', 'asc') + .orderBy('created_at', 'desc'); + + if (!includeHidden) { + query = query.where('is_hidden', formatBoolean(false)); + } + + if (categoryId) { + query = query.where('category_id', categoryId); + } + + if (uploadSource) { + query = query.where('upload_source', uploadSource); + } + + return await query.select('*'); +}; + +/** + * Get a photo by ID + * @param {number} photoId - Photo ID + * @returns {Promise} + */ +const getPhotoById = async (photoId) => { + return await db('photos').where('id', photoId).first(); +}; + +/** + * Get photo count for an event + * @param {number} eventId - Event ID + * @returns {Promise} + */ +const getPhotoCount = async (eventId) => { + const result = await db('photos') + .where('event_id', eventId) + .where('is_hidden', formatBoolean(false)) + .count('id as count') + .first(); + return result?.count || 0; +}; + +/** + * Update photo metadata + * @param {number} photoId - Photo ID + * @param {Object} updates - Fields to update + * @returns {Promise} + */ +const updatePhoto = async (photoId, updates) => { + // Don't allow updating certain fields + delete updates.id; + delete updates.event_id; + delete updates.file_path; + delete updates.created_at; + + // Handle boolean fields + if (updates.is_hidden !== undefined) { + updates.is_hidden = formatBoolean(updates.is_hidden); + } + if (updates.is_hero !== undefined) { + updates.is_hero = formatBoolean(updates.is_hero); + } + + await db('photos').where('id', photoId).update(updates); + return { success: true }; +}; + +/** + * Delete a photo (soft delete by marking hidden, or hard delete) + * @param {number} photoId - Photo ID + * @param {Object} options - Delete options + * @param {boolean} options.hard - Hard delete (remove file) + * @returns {Promise} + */ +const deletePhoto = async (photoId, options = {}) => { + const { hard = false } = options; + + if (hard) { + const photo = await getPhotoById(photoId); + if (photo) { + // Delete the actual file + const filePath = path.join(getStoragePath(), photo.file_path); + try { + await fs.unlink(filePath); + } catch (err) { + // File might not exist, continue with database deletion + } + + // Delete thumbnail if exists + if (photo.thumbnail_path) { + const thumbPath = path.join(getStoragePath(), photo.thumbnail_path); + try { + await fs.unlink(thumbPath); + } catch (err) { + // Thumbnail might not exist + } + } + + // Delete from database + await db('photos').where('id', photoId).delete(); + } + } else { + // Soft delete + await db('photos').where('id', photoId).update({ is_hidden: formatBoolean(true) }); + } + + return { success: true }; +}; + +/** + * Bulk delete photos + * @param {number[]} photoIds - Array of photo IDs + * @param {Object} options - Delete options + * @returns {Promise} + */ +const bulkDeletePhotos = async (photoIds, options = {}) => { + const { hard = false } = options; + + if (hard) { + for (const photoId of photoIds) { + await deletePhoto(photoId, { hard: true }); + } + } else { + await db('photos').whereIn('id', photoIds).update({ is_hidden: formatBoolean(true) }); + } + + return { success: true, count: photoIds.length }; +}; + +/** + * Update photo sort order + * @param {Array<{id: number, sort_order: number}>} photoOrders + * @returns {Promise} + */ +const updateSortOrder = async (photoOrders) => { + const trx = await db.transaction(); + + try { + for (const { id, sort_order } of photoOrders) { + await trx('photos').where('id', id).update({ sort_order }); + } + await trx.commit(); + return { success: true }; + } catch (error) { + await trx.rollback(); + throw error; + } +}; + +/** + * Move photos to a category + * @param {number[]} photoIds - Photo IDs + * @param {number|null} categoryId - Target category ID + * @returns {Promise} + */ +const moveToCategory = async (photoIds, categoryId) => { + await db('photos').whereIn('id', photoIds).update({ category_id: categoryId }); + return { success: true, count: photoIds.length }; +}; + +/** + * Set hero photo for an event + * @param {number} eventId - Event ID + * @param {number} photoId - Photo ID to set as hero + * @returns {Promise} + */ +const setHeroPhoto = async (eventId, photoId) => { + const trx = await db.transaction(); + + try { + // Remove hero status from all photos in event + await trx('photos') + .where('event_id', eventId) + .update({ is_hero: formatBoolean(false) }); + + // Set new hero photo + await trx('photos') + .where('id', photoId) + .where('event_id', eventId) + .update({ is_hero: formatBoolean(true) }); + + // Update event hero_photo_id + await trx('events') + .where('id', eventId) + .update({ hero_photo_id: photoId }); + + await trx.commit(); + return { success: true }; + } catch (error) { + await trx.rollback(); + throw error; + } +}; + +/** + * Get photos by category + * @param {number} eventId - Event ID + * @returns {Promise} + */ +const getPhotosByCategory = async (eventId) => { + const photos = await getPhotosForEvent(eventId); + const categories = await db('categories') + .where('event_id', eventId) + .orWhereNull('event_id') + .orderBy('sort_order', 'asc'); + + const grouped = { + uncategorized: photos.filter(p => !p.category_id) + }; + + for (const category of categories) { + grouped[category.id] = photos.filter(p => p.category_id === category.id); + } + + return { photos: grouped, categories }; +}; + +module.exports = { + getPhotosForEvent, + getPhotoById, + getPhotoCount, + updatePhoto, + deletePhoto, + bulkDeletePhotos, + updateSortOrder, + moveToCategory, + setHeroPhoto, + getPhotosByCategory +}; diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js new file mode 100644 index 00000000..55418809 --- /dev/null +++ b/backend/src/services/settingsService.js @@ -0,0 +1,267 @@ +/** + * Settings Service Layer + * Handles all settings-related business logic + * + * @module services/settingsService + */ + +const { db } = require('../database/db'); +const { parseBooleanInput, parseNumberInput } = require('../utils/parsers'); + +/** + * Get all settings + * @returns {Promise} + */ +const getAllSettings = async () => { + const settings = await db('settings').select('*'); + const settingsMap = {}; + + for (const setting of settings) { + settingsMap[setting.key] = parseSettingValue(setting.value, setting.type); + } + + return settingsMap; +}; + +/** + * Get a specific setting + * @param {string} key - Setting key + * @param {*} defaultValue - Default value if not found + * @returns {Promise<*>} + */ +const getSetting = async (key, defaultValue = null) => { + const setting = await db('settings').where('key', key).first(); + if (!setting) { + return defaultValue; + } + return parseSettingValue(setting.value, setting.type); +}; + +/** + * Get multiple settings by prefix + * @param {string} prefix - Setting key prefix + * @returns {Promise} + */ +const getSettingsByPrefix = async (prefix) => { + const settings = await db('settings') + .where('key', 'like', `${prefix}%`) + .select('*'); + + const settingsMap = {}; + for (const setting of settings) { + settingsMap[setting.key] = parseSettingValue(setting.value, setting.type); + } + + return settingsMap; +}; + +/** + * Update a setting + * @param {string} key - Setting key + * @param {*} value - Setting value + * @param {string} type - Value type (string, boolean, number, json) + * @returns {Promise} + */ +const updateSetting = async (key, value, type = 'string') => { + const serializedValue = serializeSettingValue(value, type); + + const exists = await db('settings').where('key', key).first(); + if (exists) { + await db('settings').where('key', key).update({ + value: serializedValue, + type, + updated_at: new Date() + }); + } else { + await db('settings').insert({ + key, + value: serializedValue, + type, + created_at: new Date(), + updated_at: new Date() + }); + } + + return { success: true, key, value }; +}; + +/** + * Update multiple settings + * @param {Object} settings - Key-value pairs of settings + * @returns {Promise} + */ +const updateSettings = async (settings) => { + const trx = await db.transaction(); + + try { + for (const [key, value] of Object.entries(settings)) { + const type = inferSettingType(value); + const serializedValue = serializeSettingValue(value, type); + + const exists = await trx('settings').where('key', key).first(); + if (exists) { + await trx('settings').where('key', key).update({ + value: serializedValue, + type, + updated_at: new Date() + }); + } else { + await trx('settings').insert({ + key, + value: serializedValue, + type, + created_at: new Date(), + updated_at: new Date() + }); + } + } + + await trx.commit(); + return { success: true, count: Object.keys(settings).length }; + } catch (error) { + await trx.rollback(); + throw error; + } +}; + +/** + * Delete a setting + * @param {string} key - Setting key + * @returns {Promise} + */ +const deleteSetting = async (key) => { + await db('settings').where('key', key).delete(); + return { success: true }; +}; + +/** + * Get public settings (safe to expose to frontend) + * @returns {Promise} + */ +const getPublicSettings = async () => { + const publicKeys = [ + 'branding_site_name', + 'branding_logo_url', + 'branding_favicon_url', + 'branding_primary_color', + 'branding_accent_color', + 'general_default_expiration_days', + 'recaptcha_enabled', + 'recaptcha_site_key', + 'event_require_customer_name', + 'event_require_customer_email', + 'event_require_admin_email' + ]; + + const settings = await db('settings') + .whereIn('key', publicKeys) + .select('*'); + + const settingsMap = {}; + for (const setting of settings) { + settingsMap[setting.key] = parseSettingValue(setting.value, setting.type); + } + + return settingsMap; +}; + +/** + * Get branding settings + * @returns {Promise} + */ +const getBrandingSettings = async () => { + return await getSettingsByPrefix('branding_'); +}; + +/** + * Get email settings + * @returns {Promise} + */ +const getEmailSettings = async () => { + return await getSettingsByPrefix('email_'); +}; + +/** + * Get storage settings + * @returns {Promise} + */ +const getStorageSettings = async () => { + return await getSettingsByPrefix('storage_'); +}; + +// Helper functions + +/** + * Parse a setting value based on type + * @param {string} value - Raw value + * @param {string} type - Value type + * @returns {*} + */ +const parseSettingValue = (value, type) => { + if (value === null || value === undefined) { + return null; + } + + switch (type) { + case 'boolean': + return parseBooleanInput(value, false); + case 'number': + return parseNumberInput(value, 0); + case 'json': + try { + return JSON.parse(value); + } catch { + return null; + } + default: + return value; + } +}; + +/** + * Serialize a setting value for storage + * @param {*} value - Value to serialize + * @param {string} type - Value type + * @returns {string} + */ +const serializeSettingValue = (value, type) => { + if (value === null || value === undefined) { + return null; + } + + switch (type) { + case 'boolean': + return String(value === true || value === 'true' || value === 1); + case 'number': + return String(value); + case 'json': + return JSON.stringify(value); + default: + return String(value); + } +}; + +/** + * Infer setting type from value + * @param {*} value - Value to infer type from + * @returns {string} + */ +const inferSettingType = (value) => { + if (typeof value === 'boolean') return 'boolean'; + if (typeof value === 'number') return 'number'; + if (typeof value === 'object') return 'json'; + return 'string'; +}; + +module.exports = { + getAllSettings, + getSetting, + getSettingsByPrefix, + updateSetting, + updateSettings, + deleteSetting, + getPublicSettings, + getBrandingSettings, + getEmailSettings, + getStorageSettings +};