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.
This commit is contained in:
@@ -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<Array>}
|
||||||
|
*/
|
||||||
|
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<Object|null>}
|
||||||
|
*/
|
||||||
|
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<number>}
|
||||||
|
*/
|
||||||
|
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<Object>}
|
||||||
|
*/
|
||||||
|
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<Object>}
|
||||||
|
*/
|
||||||
|
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<Object>}
|
||||||
|
*/
|
||||||
|
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<Object>}
|
||||||
|
*/
|
||||||
|
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<Object>}
|
||||||
|
*/
|
||||||
|
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<Object>}
|
||||||
|
*/
|
||||||
|
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<Object>}
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
};
|
||||||
@@ -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<Object>}
|
||||||
|
*/
|
||||||
|
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<Object>}
|
||||||
|
*/
|
||||||
|
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<Object>}
|
||||||
|
*/
|
||||||
|
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<Object>}
|
||||||
|
*/
|
||||||
|
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<Object>}
|
||||||
|
*/
|
||||||
|
const deleteSetting = async (key) => {
|
||||||
|
await db('settings').where('key', key).delete();
|
||||||
|
return { success: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get public settings (safe to expose to frontend)
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
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<Object>}
|
||||||
|
*/
|
||||||
|
const getBrandingSettings = async () => {
|
||||||
|
return await getSettingsByPrefix('branding_');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get email settings
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
const getEmailSettings = async () => {
|
||||||
|
return await getSettingsByPrefix('email_');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get storage settings
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user