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