To update your installation, log in to the admin panel and click on the "Update Available" notification. You'll find environment-specific instructions there.
+
+
+
Reminder: Always backup your database before updating to ensure you can recover if anything goes wrong.
+
+
+
Best regards,
+Your PicPeak Installation
`,
+ body_text_en: `A New Version of PicPeak is Available
+
+Great news! A new version of PicPeak is available for your installation.
+
+Current Version: {{current_version}}
+New Version: {{new_version}}
+Channel: {{channel}}
+
+What's New?
+Check the release notes to see what's included in this update:
+{{release_notes_url}}
+
+How to Update
+To update your installation, log in to the admin panel and click on the "Update Available" notification. You'll find environment-specific instructions there.
+
+REMINDER: Always backup your database before updating to ensure you can recover if anything goes wrong.
+
+Best regards,
+Your PicPeak Installation`,
+ body_html_de: `
+
Eine neue Version von PicPeak ist verfugbar
+
+
Gute Neuigkeiten! Eine neue Version von PicPeak ist fur Ihre Installation verfugbar.
+
+
+
Aktuelle Version: {{current_version}}
+
Neue Version: {{new_version}}
+
Kanal: {{channel}}
+
+
+
Was ist neu?
+
Schauen Sie sich die Versionshinweise an, um zu sehen, was in diesem Update enthalten ist:
Um Ihre Installation zu aktualisieren, melden Sie sich im Admin-Panel an und klicken Sie auf die Benachrichtigung "Update verfugbar". Dort finden Sie umgebungsspezifische Anweisungen.
+
+
+
Erinnerung: Erstellen Sie immer ein Backup Ihrer Datenbank, bevor Sie aktualisieren, um sicherzustellen, dass Sie im Fehlerfall wiederherstellen konnen.
+
+
+
Mit freundlichen Grussen,
+Ihre PicPeak-Installation
`,
+ body_text_de: `Eine neue Version von PicPeak ist verfugbar
+
+Gute Neuigkeiten! Eine neue Version von PicPeak ist fur Ihre Installation verfugbar.
+
+Aktuelle Version: {{current_version}}
+Neue Version: {{new_version}}
+Kanal: {{channel}}
+
+Was ist neu?
+Schauen Sie sich die Versionshinweise an, um zu sehen, was in diesem Update enthalten ist:
+{{release_notes_url}}
+
+So aktualisieren Sie
+Um Ihre Installation zu aktualisieren, melden Sie sich im Admin-Panel an und klicken Sie auf die Benachrichtigung "Update verfugbar". Dort finden Sie umgebungsspezifische Anweisungen.
+
+ERINNERUNG: Erstellen Sie immer ein Backup Ihrer Datenbank, bevor Sie aktualisieren, um sicherzustellen, dass Sie im Fehlerfall wiederherstellen konnen.
+
+Mit freundlichen Grussen,
+Ihre PicPeak-Installation`,
+ variables: JSON.stringify(['current_version', 'new_version', 'channel', 'release_notes_url'])
+ });
+ }
+
+ console.log('Migration 070_add_update_notification_settings completed');
+};
+
+exports.down = async function(knex) {
+ console.log('Rollback: 070_add_update_notification_settings');
+
+ // Remove settings
+ await knex('app_settings')
+ .whereIn('setting_key', [
+ 'update_email_notifications_enabled',
+ 'update_email_recipients',
+ 'last_notified_version'
+ ])
+ .del();
+
+ // Remove email template
+ await knex('email_templates')
+ .where('template_key', 'version_update_available')
+ .del();
+};
diff --git a/backend/migrations/core/071_add_captured_at.js b/backend/migrations/core/071_add_captured_at.js
new file mode 100644
index 00000000..3187af6b
--- /dev/null
+++ b/backend/migrations/core/071_add_captured_at.js
@@ -0,0 +1,42 @@
+/**
+ * Migration 071: Add captured_at column to photos table
+ * - Stores the original capture date from EXIF metadata
+ * - Enables sorting photos by capture date instead of upload date
+ */
+
+const { addColumnIfNotExists } = require('../helpers');
+
+exports.up = async function(knex) {
+ console.log('Running migration: 071_add_captured_at');
+
+ // Add captured_at column to photos table
+ await addColumnIfNotExists(knex, 'photos', 'captured_at', (table) => {
+ table.datetime('captured_at').nullable();
+ });
+
+ // Add index for sorting performance
+ const indexExists = await knex.schema.hasIndex
+ ? await knex.schema.hasIndex('photos', 'idx_photos_captured_at')
+ : false;
+
+ if (!indexExists) {
+ // Use raw query for index creation with IF NOT EXISTS
+ const client = knex.client.config.client;
+ if (client === 'pg') {
+ await knex.raw('CREATE INDEX IF NOT EXISTS idx_photos_captured_at ON photos(captured_at)');
+ } else if (client === 'sqlite3' || client === 'better-sqlite3') {
+ // SQLite doesn't support IF NOT EXISTS for indexes, so we need to check first
+ const existingIndexes = await knex.raw("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_photos_captured_at'");
+ if (existingIndexes.length === 0) {
+ await knex.raw('CREATE INDEX idx_photos_captured_at ON photos(captured_at)');
+ }
+ }
+ }
+
+ console.log('Migration 071_add_captured_at completed');
+};
+
+exports.down = async function(knex) {
+ console.log('Rollback: 071_add_captured_at');
+ // Keep column for safe rollback (intentionally no-op)
+};
diff --git a/backend/package-lock.json b/backend/package-lock.json
index d3d9896a..77cb5a23 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -20,6 +20,7 @@
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.0.3",
+ "exifr": "^7.1.3",
"express": "^4.18.2",
"express-rate-limit": "^6.7.0",
"express-validator": "^7.0.1",
@@ -5730,6 +5731,12 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/exifr": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz",
+ "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==",
+ "license": "MIT"
+ },
"node_modules/exit": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
diff --git a/backend/package.json b/backend/package.json
index 13e69bc7..205d0768 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -25,6 +25,7 @@
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.0.3",
+ "exifr": "^7.1.3",
"express": "^4.18.2",
"express-rate-limit": "^6.7.0",
"express-validator": "^7.0.1",
diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js
index e1a7bb81..e02fc42c 100644
--- a/backend/src/routes/adminPhotos.js
+++ b/backend/src/routes/adminPhotos.js
@@ -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);
diff --git a/backend/src/routes/adminSystem.js b/backend/src/routes/adminSystem.js
index da93b30c..e44d32a0 100644
--- a/backend/src/routes/adminSystem.js
+++ b/backend/src/routes/adminSystem.js
@@ -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;
diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js
index 5898ff5f..37fd1932 100644
--- a/backend/src/routes/gallery.js
+++ b/backend/src/routes/gallery.js
@@ -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) {
diff --git a/backend/src/services/environmentService.js b/backend/src/services/environmentService.js
new file mode 100644
index 00000000..1d7fbd99
--- /dev/null
+++ b/backend/src/services/environmentService.js
@@ -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
+};
diff --git a/backend/src/services/imageProcessor.js b/backend/src/services/imageProcessor.js
index 322f5ccf..dae55103 100644
--- a/backend/src/services/imageProcessor.js
+++ b/backend/src/services/imageProcessor.js
@@ -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
};
diff --git a/backend/src/services/updateNotificationService.js b/backend/src/services/updateNotificationService.js
new file mode 100644
index 00000000..d1563925
--- /dev/null
+++ b/backend/src/services/updateNotificationService.js
@@ -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
+};
diff --git a/frontend/src/components/admin/UpdateInstructionsDialog.tsx b/frontend/src/components/admin/UpdateInstructionsDialog.tsx
new file mode 100644
index 00000000..932c58bc
--- /dev/null
+++ b/frontend/src/components/admin/UpdateInstructionsDialog.tsx
@@ -0,0 +1,357 @@
+import React, { useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import {
+ X,
+ ExternalLink,
+ Copy,
+ Check,
+ AlertTriangle,
+ Server,
+ Terminal,
+ CheckCircle2,
+ Circle
+} from 'lucide-react';
+import { api } from '../../config/api';
+
+interface UpdateStep {
+ description: string;
+ command: string;
+ note?: string;
+ optional?: boolean;
+}
+
+interface PreCheck {
+ id: string;
+ text: string;
+ required: boolean;
+}
+
+interface UpdateInstructions {
+ environmentName: string;
+ preChecks: PreCheck[];
+ steps: UpdateStep[];
+ postChecks: string[];
+ warnings: string[];
+}
+
+interface Environment {
+ type: 'docker' | 'git' | 'standalone';
+ isDocker: boolean;
+ isGit: boolean;
+ hasDockerCompose: boolean;
+ platform: string;
+ nodeVersion: string;
+ appVersion: string;
+}
+
+interface UpdateInstructionsResponse {
+ enabled?: boolean;
+ updateAvailable: boolean;
+ currentVersion: string;
+ targetVersion?: string;
+ channel?: string;
+ environment?: Environment;
+ instructions?: UpdateInstructions;
+ releaseNotesUrl?: string;
+ message?: string;
+}
+
+async function fetchUpdateInstructions(): Promise {
+ const response = await api.get('/admin/system/updates/instructions');
+ return response.data;
+}
+
+interface UpdateInstructionsDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ targetVersion?: string;
+}
+
+export const UpdateInstructionsDialog: React.FC = ({
+ isOpen,
+ onClose,
+ targetVersion
+}) => {
+ const { t } = useTranslation();
+ const [checkedItems, setCheckedItems] = useState>(new Set());
+ const [copiedCommand, setCopiedCommand] = useState(null);
+
+ const { data, isLoading, error } = useQuery({
+ queryKey: ['update-instructions'],
+ queryFn: fetchUpdateInstructions,
+ enabled: isOpen,
+ staleTime: 5 * 60 * 1000 // 5 minutes
+ });
+
+ if (!isOpen) return null;
+
+ const handleCheckItem = (id: string) => {
+ const newChecked = new Set(checkedItems);
+ if (newChecked.has(id)) {
+ newChecked.delete(id);
+ } else {
+ newChecked.add(id);
+ }
+ setCheckedItems(newChecked);
+ };
+
+ const copyToClipboard = async (command: string, id: string) => {
+ try {
+ await navigator.clipboard.writeText(command);
+ setCopiedCommand(id);
+ setTimeout(() => setCopiedCommand(null), 2000);
+ } catch (err) {
+ console.error('Failed to copy:', err);
+ }
+ };
+
+ const copyAllCommands = async () => {
+ if (!data?.instructions?.steps) return;
+ const allCommands = data.instructions.steps
+ .filter(step => !step.command.startsWith('#'))
+ .map(step => step.command)
+ .join('\n');
+ try {
+ await navigator.clipboard.writeText(allCommands);
+ setCopiedCommand('all');
+ setTimeout(() => setCopiedCommand(null), 2000);
+ } catch (err) {
+ console.error('Failed to copy:', err);
+ }
+ };
+
+ const requiredChecks = data?.instructions?.preChecks.filter(c => c.required) || [];
+ const allRequiredChecked = requiredChecks.every(check => checkedItems.has(check.id));
+
+ return (
+