feat: add update instructions dialog, email notifications, and capture date sorting

- Add Update Instructions Dialog with environment-specific commands (Docker/Git/Standalone)
- Add email notification settings for new version alerts
- Add "Sort by Capture Date" option using EXIF metadata extraction
- Fix E2E tests by loading environment variables via dotenv
- Add test-images/ and backend/*.db to .gitignore

Closes #181
This commit is contained in:
Paul Nothaft
2026-02-16 16:23:57 +01:00
parent 5cef7fdd18
commit 50c09904a9
25 changed files with 1939 additions and 28 deletions
+13 -3
View File
@@ -5,7 +5,7 @@ const fs = require('fs').promises;
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { generateThumbnail, ensureThumbnail } = require('../services/imageProcessor');
const { generateThumbnail, ensureThumbnail, extractCaptureDate } = require('../services/imageProcessor');
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const { escapeLikePattern } = require('../utils/sqlSecurity');
const { validateUploadedFiles } = require('../middleware/uploadValidation');
@@ -253,7 +253,16 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
const finalPath = path.join(finalDestPath, newFilename);
const storagePath = getStoragePath();
const relativePath = path.relative(path.join(storagePath, 'events/active'), finalPath);
// Extract capture date from EXIF metadata
let capturedAt = null;
try {
capturedAt = await extractCaptureDate(tempPath);
} catch (exifError) {
// Non-fatal - just log and continue without capture date
console.log(`Could not extract EXIF date for ${file.originalname}`);
}
// Prepare photo data for batch insert
const photoData = {
event_id: parseInt(eventId),
@@ -263,7 +272,8 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
thumbnail_path: null, // Will generate after successful commit
type: photoType,
category_id: parsedCategoryId, // Save the selected category
size_bytes: tempStats.size // Use actual file size from stat
size_bytes: tempStats.size, // Use actual file size from stat
captured_at: capturedAt // EXIF capture date (if available)
};
batchPhotos.push(photoData);
+111
View File
@@ -8,6 +8,12 @@ const os = require('os');
const { formatBoolean } = require('../utils/dbCompat');
const logger = require('../utils/logger');
const { checkForUpdates, getCurrentChannel } = require('../services/updateCheckService');
const { detectEnvironment, generateUpdateInstructions } = require('../services/environmentService');
const {
checkAndNotifyUpdates,
sendUpdateNotificationNow,
getUpdateNotificationSettings
} = require('../services/updateNotificationService');
const router = express.Router();
// Get system version
@@ -65,6 +71,47 @@ router.get('/updates', adminAuth, requirePermission('settings.view'), async (req
}
});
// Get update instructions for current environment
router.get('/updates/instructions', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
// Check if update checking is enabled
const updateCheckEnabled = process.env.UPDATE_CHECK_ENABLED !== 'false';
if (!updateCheckEnabled) {
return res.json({
enabled: false,
message: 'Update checking is disabled'
});
}
const env = await detectEnvironment();
const updateInfo = await checkForUpdates();
if (!updateInfo.updateAvailable) {
return res.json({
updateAvailable: false,
currentVersion: updateInfo.current,
message: 'You are running the latest version'
});
}
const instructions = generateUpdateInstructions(env, updateInfo.latest.forChannel);
res.json({
updateAvailable: true,
currentVersion: updateInfo.current,
targetVersion: updateInfo.latest.forChannel,
channel: updateInfo.channel,
environment: env,
instructions,
releaseNotesUrl: `https://github.com/the-luap/picpeak/releases/tag/v${updateInfo.latest.forChannel}`
});
} catch (error) {
logger.error('Error generating update instructions:', error);
res.status(500).json({ error: 'Failed to generate update instructions' });
}
});
// Get comprehensive system status
router.get('/status', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
@@ -262,4 +309,68 @@ router.get('/database', adminAuth, requirePermission('settings.view'), async (re
}
});
// Get update notification settings
router.get('/updates/notifications', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const settings = await getUpdateNotificationSettings();
res.json(settings);
} catch (error) {
logger.error('Error fetching update notification settings:', error);
res.status(500).json({ error: 'Failed to fetch update notification settings' });
}
});
// Update notification settings
router.put('/updates/notifications', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const { enabled, recipients } = req.body;
if (typeof enabled !== 'undefined') {
await db('app_settings')
.where('setting_key', 'update_email_notifications_enabled')
.update({
setting_value: JSON.stringify(enabled === true),
updated_at: db.fn.now()
});
}
if (typeof recipients !== 'undefined') {
await db('app_settings')
.where('setting_key', 'update_email_recipients')
.update({
setting_value: JSON.stringify(recipients || ''),
updated_at: db.fn.now()
});
}
const updatedSettings = await getUpdateNotificationSettings();
res.json({ success: true, settings: updatedSettings });
} catch (error) {
logger.error('Error updating notification settings:', error);
res.status(500).json({ error: 'Failed to update notification settings' });
}
});
// Manually trigger update notification email
router.post('/updates/notifications/send', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const result = await sendUpdateNotificationNow();
res.json(result);
} catch (error) {
logger.error('Error sending update notification:', error);
res.status(500).json({ error: 'Failed to send update notification' });
}
});
// Check and send update notifications (called on admin login or periodically)
router.post('/updates/notifications/check', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const result = await checkAndNotifyUpdates();
res.json(result);
} catch (error) {
logger.error('Error checking for update notifications:', error);
res.status(500).json({ error: 'Failed to check for update notifications' });
}
});
module.exports = router;
+20 -6
View File
@@ -187,8 +187,8 @@ router.get('/:slug/info', async (req, res) => {
// Get all photos
router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
try {
// Get filter parameters from query
const { filter, guest_id } = req.query;
// Get filter and sort parameters from query
const { filter, guest_id, sort = 'upload_date', order = 'desc' } = req.query;
// Get watermark settings to generate cache-busting version for URLs
const watermarkSettings = await watermarkService.getWatermarkSettings();
@@ -196,11 +196,25 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
? `wm=${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
: '';
// First get all photos
let photos = await db('photos')
// Build the query with sorting
const sortOrder = order === 'asc' ? 'asc' : 'desc';
let photosQuery = db('photos')
.where('photos.event_id', req.event.id)
.select('photos.*')
.orderBy('photos.uploaded_at', 'desc');
.select('photos.*');
// Apply sort option
if (sort === 'capture_date') {
// Sort by capture date, falling back to uploaded_at if capture date is null
photosQuery = photosQuery.orderByRaw('COALESCE(photos.captured_at, photos.uploaded_at) ' + sortOrder);
} else if (sort === 'filename') {
photosQuery = photosQuery.orderBy('photos.filename', sortOrder);
} else {
// Default: sort by upload date
photosQuery = photosQuery.orderBy('photos.uploaded_at', sortOrder);
}
// Execute the query
let photos = await photosQuery;
// Apply filtering if requested (supports global stats + per-guest interactions)
if (filter) {
+195
View File
@@ -0,0 +1,195 @@
/**
* Environment Detection Service
* Detects the deployment environment and generates update instructions accordingly.
*/
const fs = require('fs');
const path = require('path');
const logger = require('../utils/logger');
/**
* Detect the current deployment environment
* @returns {Object} Environment information
*/
async function detectEnvironment() {
// Check for Docker environment
const isDocker = fs.existsSync('/.dockerenv') ||
process.env.DOCKER_CONTAINER === 'true';
// Determine project root (services -> src -> backend)
const projectRoot = path.join(__dirname, '../../..');
// Check for git repository
const isGit = fs.existsSync(path.join(projectRoot, '.git'));
// Check for docker-compose files
const hasDockerCompose = fs.existsSync(path.join(projectRoot, 'docker-compose.yml')) ||
fs.existsSync(path.join(projectRoot, 'docker-compose.yaml'));
// Get app version
let appVersion = '0.0.0';
try {
const packagePath = path.join(__dirname, '../../package.json');
const packageContent = fs.readFileSync(packagePath, 'utf8');
const packageJson = JSON.parse(packageContent);
appVersion = packageJson.version || '0.0.0';
} catch (err) {
logger.warn('Could not read package.json for version:', err.message);
}
// Determine environment type
let type;
if (isDocker) {
type = 'docker';
} else if (isGit) {
type = 'git';
} else {
type = 'standalone';
}
return {
type,
isDocker,
isGit,
hasDockerCompose,
platform: process.platform,
nodeVersion: process.version,
appVersion
};
}
/**
* Generate environment-specific update instructions
* @param {Object} env - Environment info from detectEnvironment()
* @param {string} targetVersion - Target version to update to
* @returns {Object} Update instructions with pre-checks, steps, and post-checks
*/
function generateUpdateInstructions(env, targetVersion) {
const instructions = {
preChecks: [
{
id: 'backup',
text: 'I have backed up my database',
required: true
},
{
id: 'no-uploads',
text: 'No uploads are currently in progress',
required: true
},
{
id: 'downtime-aware',
text: 'I understand the application will restart during update',
required: false
}
],
steps: [],
postChecks: [
'Verify the application starts correctly',
'Check the version in Admin -> System',
'Review release notes for any breaking changes or required actions'
],
warnings: []
};
if (env.isDocker) {
instructions.environmentName = 'Docker';
instructions.steps = [
{
description: 'Pull latest images',
command: 'docker compose pull',
note: 'Downloads the new version images'
},
{
description: 'Recreate containers with new images',
command: 'docker compose up -d',
note: 'Restarts containers with new version'
},
{
description: 'Watch logs for startup (optional)',
command: 'docker compose logs -f backend',
note: 'Press Ctrl+C to exit logs',
optional: true
}
];
instructions.warnings.push('Make sure you are in the directory containing your docker-compose.yml file');
} else if (env.isGit) {
instructions.environmentName = 'Git (Development)';
instructions.steps = [
{
description: 'Fetch latest changes',
command: 'git fetch origin',
note: 'Downloads references from remote'
},
{
description: 'Switch to new version tag',
command: `git checkout v${targetVersion}`,
note: 'Switches to the release version'
},
{
description: 'Install backend dependencies',
command: 'cd backend && npm install',
note: 'Updates npm packages'
},
{
description: 'Build frontend',
command: 'cd frontend && npm install && npm run build',
note: 'Compiles the frontend application'
},
{
description: 'Run database migrations',
command: 'cd backend && npm run migrate',
note: 'Updates database schema'
},
{
description: 'Restart application',
command: '# Restart your application (pm2, systemd, etc.)',
note: 'Method depends on your setup - e.g., pm2 restart picpeak'
}
];
instructions.warnings.push('Adjust the restart command based on your process manager (pm2, systemd, etc.)');
} else {
instructions.environmentName = 'Standalone';
instructions.steps = [
{
description: 'Download release archive',
command: `# Download v${targetVersion} from GitHub Releases`,
note: `https://github.com/the-luap/picpeak/releases/tag/v${targetVersion}`
},
{
description: 'Backup current installation',
command: '# Create backup of current files',
note: 'Keep a copy of your current installation'
},
{
description: 'Extract and replace application files',
command: '# Extract release archive to installation directory',
note: 'Preserve your .env file and storage directory'
},
{
description: 'Install dependencies',
command: 'cd backend && npm install --production',
note: 'Updates npm packages'
},
{
description: 'Run database migrations',
command: 'cd backend && npm run migrate',
note: 'Updates database schema'
},
{
description: 'Restart application',
command: '# Restart your application service',
note: 'Method depends on your setup'
}
];
instructions.warnings.push('Make sure to preserve your .env file and storage directory when updating');
instructions.warnings.push('Consider creating a full backup before updating');
}
return instructions;
}
module.exports = {
detectEnvironment,
generateUpdateInstructions
};
+52 -1
View File
@@ -1,4 +1,5 @@
const sharp = require('sharp');
const exifr = require('exifr');
const path = require('path');
const fs = require('fs').promises;
const logger = require('../utils/logger');
@@ -442,6 +443,55 @@ async function ensureHeroImage(photo) {
return null;
}
/**
* Extract capture date from EXIF metadata
* @param {string} imagePath - Path to the image file
* @returns {Date|null} - The capture date or null if not available
*/
async function extractCaptureDate(imagePath) {
try {
// Parse EXIF data, looking for common date fields
const exif = await exifr.parse(imagePath, {
pick: ['DateTimeOriginal', 'CreateDate', 'DateTimeDigitized', 'ModifyDate']
});
if (!exif) {
return null;
}
// Priority order: DateTimeOriginal > CreateDate > DateTimeDigitized > ModifyDate
const captureDate = exif.DateTimeOriginal ||
exif.CreateDate ||
exif.DateTimeDigitized ||
exif.ModifyDate;
if (captureDate) {
// exifr returns Date objects directly when parsing dates
if (captureDate instanceof Date) {
// Validate the date is reasonable (not in the future, not before 1990)
const now = new Date();
const minDate = new Date('1990-01-01');
if (captureDate > minDate && captureDate <= now) {
return captureDate;
}
}
// Handle string dates if necessary
if (typeof captureDate === 'string') {
const parsed = new Date(captureDate);
if (!isNaN(parsed.getTime())) {
return parsed;
}
}
}
return null;
} catch (error) {
// Log only as debug - many images don't have EXIF data
logger.debug(`Could not extract EXIF date from ${path.basename(imagePath)}:`, error.message);
return null;
}
}
module.exports = {
generateThumbnail,
isThumbnailValid,
@@ -449,5 +499,6 @@ module.exports = {
generateVideoPlaceholder,
generateHeroImage,
isHeroValid,
ensureHeroImage
ensureHeroImage,
extractCaptureDate
};
@@ -0,0 +1,246 @@
/**
* Update Notification Service
* Checks for updates and sends email notifications to administrators.
*/
const { db } = require('../database/db');
const { checkForUpdates } = require('./updateCheckService');
const { sendTemplateEmail, initializeTransporter } = require('./emailProcessor');
const logger = require('../utils/logger');
/**
* Get update notification settings from database
*/
async function getUpdateNotificationSettings() {
try {
const settings = await db('app_settings')
.whereIn('setting_key', [
'update_email_notifications_enabled',
'update_email_recipients',
'last_notified_version'
])
.select('setting_key', 'setting_value');
const result = {};
for (const setting of settings) {
try {
result[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
result[setting.setting_key] = setting.setting_value;
}
}
return {
enabled: result.update_email_notifications_enabled === true,
recipients: result.update_email_recipients || '',
lastNotifiedVersion: result.last_notified_version || ''
};
} catch (error) {
logger.error('Error fetching update notification settings:', error);
return {
enabled: false,
recipients: '',
lastNotifiedVersion: ''
};
}
}
/**
* Update the last notified version in database
*/
async function updateLastNotifiedVersion(version) {
try {
await db('app_settings')
.where('setting_key', 'last_notified_version')
.update({
setting_value: JSON.stringify(version),
updated_at: db.fn.now()
});
} catch (error) {
logger.error('Error updating last notified version:', error);
}
}
/**
* Get admin email addresses to notify
* If recipients setting is empty, get all active admin emails
*/
async function getNotificationRecipients(recipientsSetting) {
try {
if (recipientsSetting && recipientsSetting.trim()) {
// Use configured recipients (comma-separated)
return recipientsSetting.split(',').map(email => email.trim()).filter(Boolean);
}
// Fallback: get all active admin user emails
const admins = await db('admin_users')
.where('is_active', true)
.whereNotNull('email')
.select('email');
return admins.map(admin => admin.email).filter(Boolean);
} catch (error) {
logger.error('Error fetching notification recipients:', error);
return [];
}
}
/**
* Check for updates and send notification emails if new version is available
*/
async function checkAndNotifyUpdates() {
logger.info('Update notification service: Checking for updates...');
try {
// Check if update notifications are enabled
const settings = await getUpdateNotificationSettings();
if (!settings.enabled) {
logger.info('Update email notifications are disabled');
return { notified: false, reason: 'notifications_disabled' };
}
// Check for available updates
const updateInfo = await checkForUpdates();
if (!updateInfo.updateAvailable) {
logger.info('No updates available');
return { notified: false, reason: 'no_updates' };
}
const newVersion = updateInfo.latest.forChannel;
// Check if we've already notified about this version
if (settings.lastNotifiedVersion === newVersion) {
logger.info(`Already notified about version ${newVersion}`);
return { notified: false, reason: 'already_notified' };
}
// Get recipients
const recipients = await getNotificationRecipients(settings.recipients);
if (recipients.length === 0) {
logger.warn('No recipients configured for update notifications');
return { notified: false, reason: 'no_recipients' };
}
// Ensure email transporter is initialized
await initializeTransporter();
// Send email to each recipient
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
const releaseNotesUrl = `https://github.com/the-luap/picpeak/releases/tag/v${newVersion}`;
const channelLabel = updateInfo.channel === 'beta' ? 'Beta' : 'Stable';
let successCount = 0;
let errorCount = 0;
for (const email of recipients) {
try {
await sendTemplateEmail(email, 'version_update_available', {
current_version: updateInfo.current,
new_version: newVersion,
channel: channelLabel,
release_notes_url: releaseNotesUrl,
admin_url: `${frontendUrl}/admin`
});
successCount++;
logger.info(`Update notification sent to ${email}`);
} catch (error) {
errorCount++;
logger.error(`Failed to send update notification to ${email}:`, error);
}
}
// Update last notified version
if (successCount > 0) {
await updateLastNotifiedVersion(newVersion);
logger.info(`Update notifications sent: ${successCount} success, ${errorCount} failed`);
}
return {
notified: successCount > 0,
newVersion,
successCount,
errorCount,
totalRecipients: recipients.length
};
} catch (error) {
logger.error('Error in update notification service:', error);
return { notified: false, reason: 'error', error: error.message };
}
}
/**
* Force send update notification (for manual trigger from admin UI)
*/
async function sendUpdateNotificationNow() {
logger.info('Manually triggering update notification...');
try {
// Check for available updates
const updateInfo = await checkForUpdates(true); // Force refresh
if (!updateInfo.updateAvailable) {
return { success: false, message: 'No updates available' };
}
const newVersion = updateInfo.latest.forChannel;
const settings = await getUpdateNotificationSettings();
// Get recipients
const recipients = await getNotificationRecipients(settings.recipients);
if (recipients.length === 0) {
return { success: false, message: 'No recipients configured' };
}
// Ensure email transporter is initialized
await initializeTransporter();
// Send email to each recipient
const releaseNotesUrl = `https://github.com/the-luap/picpeak/releases/tag/v${newVersion}`;
const channelLabel = updateInfo.channel === 'beta' ? 'Beta' : 'Stable';
let successCount = 0;
let errorCount = 0;
for (const email of recipients) {
try {
await sendTemplateEmail(email, 'version_update_available', {
current_version: updateInfo.current,
new_version: newVersion,
channel: channelLabel,
release_notes_url: releaseNotesUrl
});
successCount++;
} catch (error) {
errorCount++;
logger.error(`Failed to send update notification to ${email}:`, error);
}
}
// Update last notified version
if (successCount > 0) {
await updateLastNotifiedVersion(newVersion);
}
return {
success: successCount > 0,
newVersion,
successCount,
errorCount,
totalRecipients: recipients.length
};
} catch (error) {
logger.error('Error sending manual update notification:', error);
return { success: false, message: error.message };
}
}
module.exports = {
checkAndNotifyUpdates,
sendUpdateNotificationNow,
getUpdateNotificationSettings,
getNotificationRecipients
};