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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user