refactor: remove dead files, dedupe formatBytes and document numbering helpers
- Delete unused adminEvents-enhanced.js, backupService.original.js, databaseBackup.example.js, s3Storage.example.js, ThemeCustomizer.tsx - Extract shared formatBytes to utils/formatBytes.js (was copied 4x) - Centralize formatNumberInTemplate + next-document-number logic in utils/documentSequences.js (was copied in invoice/quote/contract services)
This commit is contained in:
@@ -4,6 +4,7 @@ const { adminAuth } = require('../middleware/auth');
|
|||||||
const { requirePermission } = require('../middleware/permissions');
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const { triggerManualBackup, getBackupStatus, cleanupOldBackupRuns, getBackupManifest, validateBackupManifest } = require('../services/backupService');
|
const { triggerManualBackup, getBackupStatus, cleanupOldBackupRuns, getBackupManifest, validateBackupManifest } = require('../services/backupService');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
const { formatBytes } = require('../utils/formatBytes');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
@@ -946,15 +947,6 @@ router.post('/estimate', adminAuth, requirePermission('backup.view'), async (req
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Helper function to format bytes
|
// Helper function to format bytes
|
||||||
function formatBytes(bytes, decimals = 2) {
|
|
||||||
if (bytes === 0) return '0 Bytes';
|
|
||||||
const k = 1024;
|
|
||||||
const dm = decimals < 0 ? 0 : decimals;
|
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to get backup configuration
|
// Helper function to get backup configuration
|
||||||
async function getBackupConfig() {
|
async function getBackupConfig() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
// This is a partial file showing the enhanced event creation with password validation
|
|
||||||
// Only the relevant parts are shown - merge with existing adminEvents.js
|
|
||||||
|
|
||||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
|
||||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
|
||||||
const { requirePermission } = require('../middleware/permissions');
|
|
||||||
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization');
|
|
||||||
|
|
||||||
// Enhanced event creation with password validation
|
|
||||||
// Note: This is a partial/reference file - dynamic event type validation should be implemented
|
|
||||||
// similar to adminEvents.js using eventTypeService.isValidEventType()
|
|
||||||
router.post('/', adminAuth, requirePermission('events.create'), [
|
|
||||||
body('event_type').notEmpty().trim(), // Dynamic validation via eventTypeService
|
|
||||||
body('event_name').notEmpty().trim(),
|
|
||||||
body('event_date').isDate(),
|
|
||||||
body('customer_email').isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL),
|
|
||||||
body('admin_email').isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL),
|
|
||||||
body('password').notEmpty(), // Remove the weak isLength validation
|
|
||||||
body('expiration_days').isInt({ min: 1, max: 365 }).optional(),
|
|
||||||
body('welcome_message').optional().trim(),
|
|
||||||
body('color_theme').optional().trim(),
|
|
||||||
body('allow_user_uploads').optional().isBoolean().toBoolean(),
|
|
||||||
body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
|
||||||
body('customer_name').notEmpty().trim()
|
|
||||||
], async (req, res) => {
|
|
||||||
try {
|
|
||||||
console.log('Create event request body:', req.body);
|
|
||||||
const errors = validationResult(req);
|
|
||||||
if (!errors.isEmpty()) {
|
|
||||||
console.error('Validation errors:', errors.array());
|
|
||||||
return res.status(400).json({ errors: errors.array() });
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
event_type,
|
|
||||||
event_name,
|
|
||||||
event_date,
|
|
||||||
customer_name,
|
|
||||||
customer_email,
|
|
||||||
admin_email,
|
|
||||||
password,
|
|
||||||
welcome_message = '',
|
|
||||||
color_theme = null,
|
|
||||||
expiration_days = 30,
|
|
||||||
allow_user_uploads = false,
|
|
||||||
upload_category_id = null,
|
|
||||||
photo_cap = null
|
|
||||||
} = req.body;
|
|
||||||
|
|
||||||
// Validate password strength for gallery
|
|
||||||
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
|
||||||
eventName: event_name
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!passwordValidation.valid) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Password does not meet security requirements',
|
|
||||||
details: passwordValidation.errors,
|
|
||||||
score: passwordValidation.score,
|
|
||||||
feedback: passwordValidation.feedback
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate unique slug
|
|
||||||
const baseSlug = `${event_type}-${event_name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${event_date}`;
|
|
||||||
let slug = baseSlug;
|
|
||||||
let counter = 1;
|
|
||||||
|
|
||||||
while (await db('events').where({ slug }).first()) {
|
|
||||||
slug = `${baseSlug}-${counter}`;
|
|
||||||
counter++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate share link based on configured style
|
|
||||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
|
||||||
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
|
|
||||||
|
|
||||||
// Hash password with configurable rounds
|
|
||||||
const password_hash = await bcrypt.hash(password, getBcryptRounds());
|
|
||||||
|
|
||||||
// Calculate expiration date (days after event date)
|
|
||||||
const expires_at = new Date(event_date);
|
|
||||||
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
|
|
||||||
|
|
||||||
// Create folder structure
|
|
||||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
|
||||||
const eventPath = path.join(storagePath, 'events/active', slug);
|
|
||||||
await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true });
|
|
||||||
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
|
|
||||||
|
|
||||||
// Insert into database
|
|
||||||
const insertResult = await db('events').insert({
|
|
||||||
slug,
|
|
||||||
event_type,
|
|
||||||
event_name,
|
|
||||||
event_date,
|
|
||||||
customer_name,
|
|
||||||
customer_email,
|
|
||||||
host_name: customer_name,
|
|
||||||
host_email: customer_email,
|
|
||||||
admin_email,
|
|
||||||
password_hash,
|
|
||||||
welcome_message,
|
|
||||||
color_theme,
|
|
||||||
share_link: shareLinkToStore,
|
|
||||||
share_token: shareToken,
|
|
||||||
expires_at: expires_at.toISOString(),
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
allow_user_uploads,
|
|
||||||
upload_category_id,
|
|
||||||
photo_cap: photo_cap || null
|
|
||||||
}).returning('id');
|
|
||||||
|
|
||||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
|
||||||
const eventId = insertResult[0]?.id || insertResult[0];
|
|
||||||
|
|
||||||
// Log activity
|
|
||||||
await logActivity('event_created',
|
|
||||||
{
|
|
||||||
event_type,
|
|
||||||
expires_at,
|
|
||||||
password_strength: passwordValidation.score
|
|
||||||
},
|
|
||||||
eventId,
|
|
||||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Rest of the implementation remains the same...
|
|
||||||
// Queue creation email, etc.
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating event:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to create event' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -10,6 +10,7 @@ const cron = require('node-cron');
|
|||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { queueEmail } = require('./emailProcessor');
|
const { queueEmail } = require('./emailProcessor');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
const { formatBytes } = require('../utils/formatBytes');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const backupManifest = require('./backupManifest');
|
const backupManifest = require('./backupManifest');
|
||||||
const S3StorageAdapter = require('./storage/s3Storage');
|
const S3StorageAdapter = require('./storage/s3Storage');
|
||||||
@@ -778,19 +779,6 @@ async function performRsyncBackup(config, files) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(bytes, decimals = 2) {
|
|
||||||
if (!bytes) {
|
|
||||||
return '0 Bytes';
|
|
||||||
}
|
|
||||||
|
|
||||||
const k = 1024;
|
|
||||||
const dm = decimals < 0 ? 0 : decimals;
|
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
|
|
||||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function performS3Backup(config, files) {
|
async function performS3Backup(config, files) {
|
||||||
try {
|
try {
|
||||||
const bucket = config.backup_s3_bucket;
|
const bucket = config.backup_s3_bucket;
|
||||||
|
|||||||
@@ -1,720 +0,0 @@
|
|||||||
const cron = require('node-cron');
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs').promises;
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const { exec } = require('child_process');
|
|
||||||
const { promisify } = require('util');
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
const { db } = require('../database/db');
|
|
||||||
const { queueEmail } = require('./emailProcessor');
|
|
||||||
const logger = require('../utils/logger');
|
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
|
||||||
const backupManifest = require('./backupManifest');
|
|
||||||
|
|
||||||
// Backup job reference
|
|
||||||
let backupJob = null;
|
|
||||||
let backupConfig = null;
|
|
||||||
let isRunning = false;
|
|
||||||
|
|
||||||
// Storage paths
|
|
||||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate file checksum using SHA256
|
|
||||||
*/
|
|
||||||
async function calculateChecksum(filePath) {
|
|
||||||
const hash = crypto.createHash('sha256');
|
|
||||||
const stream = require('fs').createReadStream(filePath);
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
stream.on('data', data => hash.update(data));
|
|
||||||
stream.on('end', () => resolve(hash.digest('hex')));
|
|
||||||
stream.on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get database backup information
|
|
||||||
*/
|
|
||||||
async function getDatabaseBackupInfo() {
|
|
||||||
try {
|
|
||||||
// Check for recent database backup
|
|
||||||
const recentDbBackup = await db('database_backup_runs')
|
|
||||||
.where('status', 'completed')
|
|
||||||
.orderBy('completed_at', 'desc')
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (recentDbBackup && recentDbBackup.file_path) {
|
|
||||||
return {
|
|
||||||
type: recentDbBackup.backup_type,
|
|
||||||
backupFile: recentDbBackup.file_path,
|
|
||||||
size: recentDbBackup.file_size_bytes,
|
|
||||||
checksum: recentDbBackup.checksum,
|
|
||||||
tables: recentDbBackup.statistics ? JSON.parse(recentDbBackup.statistics).tables : {},
|
|
||||||
rowCounts: recentDbBackup.table_checksums ? JSON.parse(recentDbBackup.table_checksums) : {}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: process.env.DB_TYPE === 'postgresql' ? 'postgresql' : 'sqlite',
|
|
||||||
backupFile: null,
|
|
||||||
size: 0,
|
|
||||||
checksum: null,
|
|
||||||
tables: {},
|
|
||||||
rowCounts: {}
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to get database backup info:', error);
|
|
||||||
return {
|
|
||||||
type: 'unknown',
|
|
||||||
backupFile: null,
|
|
||||||
size: 0,
|
|
||||||
checksum: null,
|
|
||||||
tables: {},
|
|
||||||
rowCounts: {}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get backup configuration from database
|
|
||||||
*/
|
|
||||||
async function getBackupConfig() {
|
|
||||||
try {
|
|
||||||
const settings = await db('app_settings')
|
|
||||||
.where('setting_type', 'backup')
|
|
||||||
.select('setting_key', 'setting_value');
|
|
||||||
|
|
||||||
const config = {};
|
|
||||||
settings.forEach(setting => {
|
|
||||||
try {
|
|
||||||
config[setting.setting_key] = JSON.parse(setting.setting_value);
|
|
||||||
} catch (e) {
|
|
||||||
config[setting.setting_key] = setting.setting_value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return config;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to get backup configuration:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get list of files to backup
|
|
||||||
*/
|
|
||||||
async function getFilesToBackup(includeArchived = true) {
|
|
||||||
const files = [];
|
|
||||||
const storagePath = getStoragePath();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Active events
|
|
||||||
const activePath = path.join(storagePath, 'events/active');
|
|
||||||
await scanDirectory(activePath, files, storagePath);
|
|
||||||
|
|
||||||
// Archived events (if enabled)
|
|
||||||
if (includeArchived) {
|
|
||||||
const archivePath = path.join(storagePath, 'events/archived');
|
|
||||||
await scanDirectory(archivePath, files, storagePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Thumbnails
|
|
||||||
const thumbsPath = path.join(storagePath, 'thumbnails');
|
|
||||||
await scanDirectory(thumbsPath, files, storagePath);
|
|
||||||
|
|
||||||
// Uploads (logos, favicons, etc.)
|
|
||||||
const uploadsPath = path.join(storagePath, 'uploads');
|
|
||||||
await scanDirectory(uploadsPath, files, storagePath);
|
|
||||||
|
|
||||||
return files;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to get files to backup:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursively scan directory for files
|
|
||||||
*/
|
|
||||||
async function scanDirectory(dirPath, fileList, basePath, excludePatterns = []) {
|
|
||||||
try {
|
|
||||||
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = path.join(dirPath, entry.name);
|
|
||||||
const relativePath = path.relative(basePath, fullPath);
|
|
||||||
|
|
||||||
// Check exclude patterns
|
|
||||||
if (excludePatterns.some(pattern => {
|
|
||||||
if (pattern.includes('*')) {
|
|
||||||
return new RegExp(pattern.replace(/\*/g, '.*')).test(entry.name);
|
|
||||||
}
|
|
||||||
return entry.name === pattern;
|
|
||||||
})) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
await scanDirectory(fullPath, fileList, basePath, excludePatterns);
|
|
||||||
} else if (entry.isFile()) {
|
|
||||||
const stats = await fs.stat(fullPath);
|
|
||||||
fileList.push({
|
|
||||||
path: fullPath,
|
|
||||||
relativePath: relativePath,
|
|
||||||
size: stats.size,
|
|
||||||
modified: stats.mtime
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code !== 'ENOENT') {
|
|
||||||
logger.error(`Failed to scan directory ${dirPath}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if file has changed since last backup
|
|
||||||
*/
|
|
||||||
async function hasFileChanged(filePath, checksum) {
|
|
||||||
try {
|
|
||||||
const fileState = await db('backup_file_states')
|
|
||||||
.where('file_path', filePath)
|
|
||||||
.first();
|
|
||||||
|
|
||||||
return !fileState || fileState.checksum !== checksum;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to check file state:', error);
|
|
||||||
return true; // Assume changed if we can't check
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update file state in database
|
|
||||||
*/
|
|
||||||
async function updateFileState(filePath, checksum, size, modified) {
|
|
||||||
try {
|
|
||||||
const existing = await db('backup_file_states')
|
|
||||||
.where('file_path', filePath)
|
|
||||||
.first();
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
file_path: filePath,
|
|
||||||
checksum: checksum,
|
|
||||||
size_bytes: size,
|
|
||||||
last_modified: modified,
|
|
||||||
last_backed_up: new Date()
|
|
||||||
};
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
await db('backup_file_states')
|
|
||||||
.where('id', existing.id)
|
|
||||||
.update(data);
|
|
||||||
} else {
|
|
||||||
await db('backup_file_states').insert(data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to update file state:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform local directory backup
|
|
||||||
*/
|
|
||||||
async function performLocalBackup(config, files) {
|
|
||||||
const destPath = config.backup_destination_path;
|
|
||||||
const storagePath = getStoragePath();
|
|
||||||
let backedUpCount = 0;
|
|
||||||
let backedUpSize = 0;
|
|
||||||
const backedUpFiles = [];
|
|
||||||
|
|
||||||
// Ensure destination exists
|
|
||||||
await fs.mkdir(destPath, { recursive: true });
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
try {
|
|
||||||
// Skip large files if configured
|
|
||||||
const maxSizeMB = config.backup_max_file_size_mb || 5000;
|
|
||||||
if (file.size > maxSizeMB * 1024 * 1024) {
|
|
||||||
logger.warn(`Skipping large file: ${file.relativePath} (${(file.size / 1024 / 1024).toFixed(2)} MB)`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate checksum
|
|
||||||
const checksum = await calculateChecksum(file.path);
|
|
||||||
file.checksum = checksum; // Add checksum to file object
|
|
||||||
|
|
||||||
// Check if file has changed
|
|
||||||
const changed = await hasFileChanged(file.relativePath, checksum);
|
|
||||||
if (!changed) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy file
|
|
||||||
const destFilePath = path.join(destPath, file.relativePath);
|
|
||||||
const destDir = path.dirname(destFilePath);
|
|
||||||
await fs.mkdir(destDir, { recursive: true });
|
|
||||||
await fs.copyFile(file.path, destFilePath);
|
|
||||||
|
|
||||||
// Update state
|
|
||||||
await updateFileState(file.relativePath, checksum, file.size, file.modified);
|
|
||||||
|
|
||||||
backedUpCount++;
|
|
||||||
backedUpSize += file.size;
|
|
||||||
backedUpFiles.push(file.relativePath);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to backup file ${file.relativePath}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { backedUpCount, backedUpSize, backedUpFiles };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform rsync backup
|
|
||||||
*/
|
|
||||||
async function performRsyncBackup(config, files) {
|
|
||||||
const storagePath = getStoragePath();
|
|
||||||
const host = config.backup_rsync_host;
|
|
||||||
const user = config.backup_rsync_user;
|
|
||||||
const remotePath = config.backup_rsync_path;
|
|
||||||
const sshKey = config.backup_rsync_ssh_key;
|
|
||||||
|
|
||||||
if (!host || !remotePath) {
|
|
||||||
throw new Error('Rsync configuration incomplete');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build rsync command
|
|
||||||
const rsyncOptions = [
|
|
||||||
'-avz', // archive, verbose, compress
|
|
||||||
'--delete', // remove deleted files
|
|
||||||
'--stats' // show statistics
|
|
||||||
];
|
|
||||||
|
|
||||||
if (sshKey) {
|
|
||||||
rsyncOptions.push(`-e "ssh -i ${sshKey} -o StrictHostKeyChecking=no"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add exclude patterns
|
|
||||||
const excludePatterns = config.backup_exclude_patterns || [];
|
|
||||||
excludePatterns.forEach(pattern => {
|
|
||||||
rsyncOptions.push(`--exclude="${pattern}"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
const source = `${storagePath}/`;
|
|
||||||
const destination = user ? `${user}@${host}:${remotePath}` : `${host}:${remotePath}`;
|
|
||||||
|
|
||||||
const rsyncCommand = `rsync ${rsyncOptions.join(' ')} "${source}" "${destination}"`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { stdout, stderr } = await execAsync(rsyncCommand);
|
|
||||||
|
|
||||||
// Parse rsync stats
|
|
||||||
const stats = parseRsyncStats(stdout);
|
|
||||||
|
|
||||||
// Update file states for successfully synced files
|
|
||||||
for (const file of files) {
|
|
||||||
try {
|
|
||||||
const checksum = await calculateChecksum(file.path);
|
|
||||||
await updateFileState(file.relativePath, checksum, file.size, file.modified);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to update state for ${file.relativePath}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
backedUpCount: stats.filesTransferred || files.length,
|
|
||||||
backedUpSize: stats.totalSize || files.reduce((sum, f) => sum + f.size, 0),
|
|
||||||
backedUpFiles: files.map(f => f.relativePath)
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Rsync backup failed:', error);
|
|
||||||
throw new Error(`Rsync backup failed: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse rsync statistics from output
|
|
||||||
*/
|
|
||||||
function parseRsyncStats(output) {
|
|
||||||
const stats = {};
|
|
||||||
|
|
||||||
// Extract files transferred
|
|
||||||
const filesMatch = output.match(/Number of files transferred: (\d+)/);
|
|
||||||
if (filesMatch) {
|
|
||||||
stats.filesTransferred = parseInt(filesMatch[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract total size
|
|
||||||
const sizeMatch = output.match(/Total file size: ([\d,]+) bytes/);
|
|
||||||
if (sizeMatch) {
|
|
||||||
stats.totalSize = parseInt(sizeMatch[1].replace(/,/g, ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform S3-compatible backup
|
|
||||||
*/
|
|
||||||
async function performS3Backup(config, files) {
|
|
||||||
// This would require AWS SDK or similar
|
|
||||||
// For now, return a placeholder
|
|
||||||
throw new Error('S3 backup not implemented yet');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run backup process
|
|
||||||
*/
|
|
||||||
async function runBackup() {
|
|
||||||
if (isRunning) {
|
|
||||||
logger.warn('Backup already running, skipping');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isRunning = true;
|
|
||||||
const startTime = new Date();
|
|
||||||
let backupRun = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get current configuration
|
|
||||||
const config = await getBackupConfig();
|
|
||||||
if (!config.backup_enabled) {
|
|
||||||
logger.info('Backup is disabled, skipping');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create backup run record
|
|
||||||
const [runId] = await db('backup_runs').insert({
|
|
||||||
started_at: startTime,
|
|
||||||
status: 'running',
|
|
||||||
backup_type: 'scheduled'
|
|
||||||
});
|
|
||||||
|
|
||||||
backupRun = { id: runId };
|
|
||||||
|
|
||||||
// Get files to backup
|
|
||||||
const files = await getFilesToBackup(config.backup_include_archived);
|
|
||||||
logger.info(`Found ${files.length} files to check for backup`);
|
|
||||||
|
|
||||||
// Perform backup based on destination type
|
|
||||||
let result;
|
|
||||||
switch (config.backup_destination_type) {
|
|
||||||
case 'local':
|
|
||||||
result = await performLocalBackup(config, files);
|
|
||||||
break;
|
|
||||||
case 'rsync':
|
|
||||||
result = await performRsyncBackup(config, files);
|
|
||||||
break;
|
|
||||||
case 's3':
|
|
||||||
result = await performS3Backup(config, files);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown backup destination type: ${config.backup_destination_type}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate duration
|
|
||||||
const endTime = new Date();
|
|
||||||
const durationSeconds = Math.round((endTime - startTime) / 1000);
|
|
||||||
|
|
||||||
// Generate backup manifest
|
|
||||||
let manifestPath = null;
|
|
||||||
try {
|
|
||||||
logger.info('Generating backup manifest...');
|
|
||||||
|
|
||||||
// Get database backup info if available
|
|
||||||
const databaseInfo = await getDatabaseBackupInfo();
|
|
||||||
|
|
||||||
// Determine if this is an incremental backup
|
|
||||||
const lastSuccessfulBackup = await db('backup_runs')
|
|
||||||
.where('status', 'completed')
|
|
||||||
.whereNot('id', runId)
|
|
||||||
.orderBy('completed_at', 'desc')
|
|
||||||
.first();
|
|
||||||
|
|
||||||
let manifest;
|
|
||||||
const manifestOptions = {
|
|
||||||
backupType: lastSuccessfulBackup ? 'incremental' : 'full',
|
|
||||||
backupPath: config.backup_destination_path || config.backup_destination_type,
|
|
||||||
files: files.filter(f => result.backedUpFiles && result.backedUpFiles.includes(f.relativePath)),
|
|
||||||
databaseInfo: databaseInfo,
|
|
||||||
parentBackupId: lastSuccessfulBackup ? lastSuccessfulBackup.manifest_id : null,
|
|
||||||
format: config.backup_manifest_format || 'json',
|
|
||||||
customMetadata: {
|
|
||||||
backup_run_id: runId,
|
|
||||||
destination_type: config.backup_destination_type,
|
|
||||||
operator: 'system',
|
|
||||||
reason: 'scheduled',
|
|
||||||
retentionDays: config.backup_retention_days || 30
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (lastSuccessfulBackup && lastSuccessfulBackup.manifest_path) {
|
|
||||||
try {
|
|
||||||
const parentManifest = await backupManifest.loadManifest(lastSuccessfulBackup.manifest_path);
|
|
||||||
manifest = await backupManifest.generateIncrementalManifest(manifestOptions, parentManifest);
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn('Failed to load parent manifest, generating full manifest:', error);
|
|
||||||
manifest = await backupManifest.generateManifest(manifestOptions);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
manifest = await backupManifest.generateManifest(manifestOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save manifest
|
|
||||||
const manifestDir = config.backup_manifest_path || path.join(config.backup_destination_path || '/backup', 'manifests');
|
|
||||||
await fs.mkdir(manifestDir, { recursive: true });
|
|
||||||
|
|
||||||
const manifestFileName = `backup-manifest-${manifest.backup.id}.${config.backup_manifest_format || 'json'}`;
|
|
||||||
manifestPath = path.join(manifestDir, manifestFileName);
|
|
||||||
await backupManifest.saveManifest(manifest, manifestPath, config.backup_manifest_format || 'json');
|
|
||||||
|
|
||||||
logger.info(`Backup manifest saved to ${manifestPath}`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to generate backup manifest:', error);
|
|
||||||
// Don't fail the entire backup for manifest generation failure
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update backup run record
|
|
||||||
await db('backup_runs')
|
|
||||||
.where('id', runId)
|
|
||||||
.update({
|
|
||||||
completed_at: endTime,
|
|
||||||
status: 'completed',
|
|
||||||
files_backed_up: result.backedUpCount,
|
|
||||||
total_size_bytes: result.backedUpSize,
|
|
||||||
duration_seconds: durationSeconds,
|
|
||||||
manifest_path: manifestPath,
|
|
||||||
manifest_id: manifestPath ? path.basename(manifestPath, path.extname(manifestPath)) : null,
|
|
||||||
statistics: JSON.stringify({
|
|
||||||
totalFilesChecked: files.length,
|
|
||||||
filesBackedUp: result.backedUpCount,
|
|
||||||
totalSize: result.backedUpSize,
|
|
||||||
averageFileSize: result.backedUpCount > 0 ? Math.round(result.backedUpSize / result.backedUpCount) : 0,
|
|
||||||
manifestGenerated: !!manifestPath
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(`Backup completed: ${result.backedUpCount} files, ${(result.backedUpSize / 1024 / 1024).toFixed(2)} MB in ${durationSeconds}s`);
|
|
||||||
|
|
||||||
// Send success email if configured
|
|
||||||
if (config.backup_email_on_success) {
|
|
||||||
// Get admin emails
|
|
||||||
const admins = await db('admin_users').where('is_active', formatBoolean(true));
|
|
||||||
for (const admin of admins) {
|
|
||||||
await queueEmail(null, admin.email, 'backup_completed', {
|
|
||||||
start_time: startTime.toISOString(),
|
|
||||||
duration: `${durationSeconds} seconds`,
|
|
||||||
files_count: result.backedUpCount.toString(),
|
|
||||||
total_size: `${(result.backedUpSize / 1024 / 1024).toFixed(2)} MB`,
|
|
||||||
backup_type: config.backup_destination_type
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Backup failed:', error);
|
|
||||||
|
|
||||||
// Update backup run record
|
|
||||||
if (backupRun) {
|
|
||||||
await db('backup_runs')
|
|
||||||
.where('id', backupRun.id)
|
|
||||||
.update({
|
|
||||||
completed_at: new Date(),
|
|
||||||
status: 'failed',
|
|
||||||
error_message: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send failure email
|
|
||||||
const config = await getBackupConfig();
|
|
||||||
if (config && config.backup_email_on_failure) {
|
|
||||||
const admins = await db('admin_users').where('is_active', formatBoolean(true));
|
|
||||||
for (const admin of admins) {
|
|
||||||
await queueEmail(null, admin.email, 'backup_failed', {
|
|
||||||
start_time: startTime.toISOString(),
|
|
||||||
backup_type: config.backup_destination_type || 'unknown',
|
|
||||||
error_message: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
isRunning = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start backup service
|
|
||||||
*/
|
|
||||||
async function startBackupService() {
|
|
||||||
try {
|
|
||||||
// Get configuration
|
|
||||||
backupConfig = await getBackupConfig();
|
|
||||||
|
|
||||||
if (!backupConfig || !backupConfig.backup_enabled) {
|
|
||||||
logger.info('Backup service is disabled');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel existing job if any
|
|
||||||
if (backupJob) {
|
|
||||||
backupJob.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schedule backup job
|
|
||||||
const schedule = backupConfig.backup_schedule || '0 2 * * *'; // Default: 2 AM daily
|
|
||||||
backupJob = cron.schedule(schedule, async () => {
|
|
||||||
logger.info('Starting scheduled backup');
|
|
||||||
await runBackup();
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(`Backup service started with schedule: ${schedule}`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to start backup service:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop backup service
|
|
||||||
*/
|
|
||||||
function stopBackupService() {
|
|
||||||
if (backupJob) {
|
|
||||||
backupJob.stop();
|
|
||||||
backupJob = null;
|
|
||||||
logger.info('Backup service stopped');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trigger manual backup
|
|
||||||
*/
|
|
||||||
async function triggerManualBackup() {
|
|
||||||
logger.info('Starting manual backup');
|
|
||||||
await runBackup();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get backup status and history
|
|
||||||
*/
|
|
||||||
async function getBackupStatus(limit = 10) {
|
|
||||||
try {
|
|
||||||
const runs = await db('backup_runs')
|
|
||||||
.orderBy('started_at', 'desc')
|
|
||||||
.limit(limit);
|
|
||||||
|
|
||||||
const lastRun = runs[0];
|
|
||||||
const isHealthy = lastRun && lastRun.status === 'completed';
|
|
||||||
|
|
||||||
// Validate manifest if exists
|
|
||||||
let manifestValid = false;
|
|
||||||
if (lastRun && lastRun.manifest_path) {
|
|
||||||
try {
|
|
||||||
const manifest = await backupManifest.loadManifest(lastRun.manifest_path);
|
|
||||||
backupManifest.validateManifest(manifest);
|
|
||||||
manifestValid = true;
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn('Manifest validation failed:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
isRunning,
|
|
||||||
isHealthy,
|
|
||||||
lastRun: lastRun ? {
|
|
||||||
...lastRun,
|
|
||||||
manifestValid
|
|
||||||
} : null,
|
|
||||||
recentRuns: runs,
|
|
||||||
nextScheduledRun: backupJob ? getNextScheduledRun() : null
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to get backup status:', error);
|
|
||||||
return {
|
|
||||||
isRunning,
|
|
||||||
isHealthy: false,
|
|
||||||
error: error.message
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get next scheduled run time
|
|
||||||
*/
|
|
||||||
function getNextScheduledRun() {
|
|
||||||
// This is a simplified version - would need proper cron parsing
|
|
||||||
const now = new Date();
|
|
||||||
const tomorrow = new Date(now);
|
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
||||||
tomorrow.setHours(2, 0, 0, 0); // Assuming default 2 AM schedule
|
|
||||||
return tomorrow.toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up old backup runs
|
|
||||||
*/
|
|
||||||
async function cleanupOldBackupRuns(retentionDays = 30) {
|
|
||||||
try {
|
|
||||||
const cutoffDate = new Date();
|
|
||||||
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
|
|
||||||
|
|
||||||
const deleted = await db('backup_runs')
|
|
||||||
.where('started_at', '<', cutoffDate)
|
|
||||||
.delete();
|
|
||||||
|
|
||||||
if (deleted > 0) {
|
|
||||||
logger.info(`Cleaned up ${deleted} old backup runs`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to cleanup old backup runs:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get backup manifest for a specific backup run
|
|
||||||
*/
|
|
||||||
async function getBackupManifest(backupRunId) {
|
|
||||||
try {
|
|
||||||
const run = await db('backup_runs')
|
|
||||||
.where('id', backupRunId)
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (!run || !run.manifest_path) {
|
|
||||||
throw new Error('Backup manifest not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const manifest = await backupManifest.loadManifest(run.manifest_path);
|
|
||||||
return {
|
|
||||||
manifest,
|
|
||||||
summary: backupManifest.generateSummaryReport(manifest)
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to get backup manifest:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate a backup manifest file
|
|
||||||
*/
|
|
||||||
async function validateBackupManifest(manifestPath) {
|
|
||||||
try {
|
|
||||||
const manifest = await backupManifest.loadManifest(manifestPath);
|
|
||||||
backupManifest.validateManifest(manifest);
|
|
||||||
return { valid: true, manifest };
|
|
||||||
} catch (error) {
|
|
||||||
return { valid: false, error: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
startBackupService,
|
|
||||||
stopBackupService,
|
|
||||||
triggerManualBackup,
|
|
||||||
getBackupStatus,
|
|
||||||
runBackup,
|
|
||||||
cleanupOldBackupRuns,
|
|
||||||
getBackupManifest,
|
|
||||||
validateBackupManifest
|
|
||||||
};
|
|
||||||
@@ -39,7 +39,7 @@ const { db, withRetry, logActivity } = require('../database/db');
|
|||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { getAppSetting } = require('../utils/appSettings');
|
const { getAppSetting } = require('../utils/appSettings');
|
||||||
const { AppError } = require('../utils/errors');
|
const { AppError } = require('../utils/errors');
|
||||||
const { claimNextSequence } = require('../utils/documentSequences');
|
const { nextDocumentNumber } = require('../utils/documentSequences');
|
||||||
const { hasColumnCached } = require('../utils/schemaCache');
|
const { hasColumnCached } = require('../utils/schemaCache');
|
||||||
const { formatShortDate } = require('../utils/dateFormatter');
|
const { formatShortDate } = require('../utils/dateFormatter');
|
||||||
const businessProfileService = require('./businessProfileService');
|
const businessProfileService = require('./businessProfileService');
|
||||||
@@ -151,14 +151,6 @@ async function maybeStoreIp(ip) {
|
|||||||
// D.2 — `ensureInt` consolidated into utils/numericHelpers.
|
// D.2 — `ensureInt` consolidated into utils/numericHelpers.
|
||||||
const { ensureInt } = require('../utils/numericHelpers');
|
const { ensureInt } = require('../utils/numericHelpers');
|
||||||
|
|
||||||
function formatNumberInTemplate(format, year, seq) {
|
|
||||||
return format
|
|
||||||
.replace(/\{YEAR\}/g, String(year))
|
|
||||||
.replace(/\{MONTH\}/g, String(new Date().getMonth() + 1).padStart(2, '0'))
|
|
||||||
.replace(/\{SEQ:(\d+)d\}/g, (_, pad) => String(seq).padStart(parseInt(pad, 10), '0'))
|
|
||||||
.replace(/\{SEQ\}/g, String(seq));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gap-free per-year contract number sequence. See
|
* Gap-free per-year contract number sequence. See
|
||||||
* utils/documentSequences.js for the locking story; migration 132
|
* utils/documentSequences.js for the locking story; migration 132
|
||||||
@@ -167,13 +159,7 @@ function formatNumberInTemplate(format, year, seq) {
|
|||||||
* emit `C-2026-AB12C3` after 5 retries.
|
* emit `C-2026-AB12C3` after 5 retries.
|
||||||
*/
|
*/
|
||||||
async function nextContractNumber(trx) {
|
async function nextContractNumber(trx) {
|
||||||
// Read through `trx` when present — getAppSetting on the global db inside an
|
return nextDocumentNumber('contract', 'crm_contracts_number_format', 'C-{YEAR}-{SEQ:04d}', trx);
|
||||||
// open transaction deadlocks the single-connection SQLite pool (the booking
|
|
||||||
// flow's prepare_contract action runs createFromQuote unattended).
|
|
||||||
const format = (await getAppSetting('crm_contracts_number_format', null, trx || db)) || 'C-{YEAR}-{SEQ:04d}';
|
|
||||||
const year = new Date().getFullYear();
|
|
||||||
const seq = await claimNextSequence('contract', year, trx);
|
|
||||||
return formatNumberInTemplate(format, year, seq);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
/**
|
|
||||||
* Database Backup Service Usage Examples
|
|
||||||
*
|
|
||||||
* This service provides comprehensive database backup functionality
|
|
||||||
* with support for both SQLite and PostgreSQL databases.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { databaseBackupService } = require('./databaseBackup');
|
|
||||||
|
|
||||||
// Example 1: Manual backup with default settings
|
|
||||||
async function manualBackup() {
|
|
||||||
try {
|
|
||||||
const result = await databaseBackupService.backup();
|
|
||||||
console.log('Backup completed:', result);
|
|
||||||
// Result includes: path, size, duration, checksum, compressionRatio
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Backup failed:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example 2: Backup with custom options
|
|
||||||
async function customBackup() {
|
|
||||||
try {
|
|
||||||
const result = await databaseBackupService.backup({
|
|
||||||
destinationPath: '/custom/backup/path',
|
|
||||||
compress: true, // Enable gzip compression
|
|
||||||
validateIntegrity: true, // Validate backup after creation
|
|
||||||
includeChecksums: true, // Calculate table checksums
|
|
||||||
noTransaction: false // Use transaction for consistency (PostgreSQL)
|
|
||||||
});
|
|
||||||
console.log('Custom backup completed:', result);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Backup failed:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example 3: Check backup progress (useful for long-running backups)
|
|
||||||
async function backupWithProgress() {
|
|
||||||
// Start backup asynchronously
|
|
||||||
const backupPromise = databaseBackupService.backup();
|
|
||||||
|
|
||||||
// Poll for progress
|
|
||||||
const progressInterval = setInterval(() => {
|
|
||||||
const progress = databaseBackupService.getProgress();
|
|
||||||
if (progress) {
|
|
||||||
console.log(`Progress: ${progress.message}`, progress.details);
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await backupPromise;
|
|
||||||
clearInterval(progressInterval);
|
|
||||||
console.log('Backup completed:', result);
|
|
||||||
} catch (error) {
|
|
||||||
clearInterval(progressInterval);
|
|
||||||
console.error('Backup failed:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example 4: Get backup history
|
|
||||||
async function getBackupHistory() {
|
|
||||||
const history = await databaseBackupService.getBackupHistory(10);
|
|
||||||
|
|
||||||
history.forEach(backup => {
|
|
||||||
console.log(`Backup ${backup.id}:`);
|
|
||||||
console.log(` Started: ${backup.started_at}`);
|
|
||||||
console.log(` Status: ${backup.status}`);
|
|
||||||
console.log(` Size: ${(backup.file_size_bytes / 1024 / 1024).toFixed(2)} MB`);
|
|
||||||
console.log(` Duration: ${backup.duration_seconds}s`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example 5: Clean up old backups
|
|
||||||
async function cleanupBackups() {
|
|
||||||
// Delete backups older than 30 days
|
|
||||||
await databaseBackupService.cleanupOldBackups(30);
|
|
||||||
console.log('Old backups cleaned up');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example 6: Get table checksums (useful for monitoring changes)
|
|
||||||
async function getTableChecksums() {
|
|
||||||
const checksums = await databaseBackupService.getTableChecksums();
|
|
||||||
|
|
||||||
console.log('Table Checksums:');
|
|
||||||
Object.entries(checksums).forEach(([table, info]) => {
|
|
||||||
console.log(` ${table}: ${info.rowCount} rows, checksum: ${info.checksum}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example 7: Using the scheduled backup service
|
|
||||||
const { startScheduledBackups, stopScheduledBackups } = require('./databaseBackup');
|
|
||||||
|
|
||||||
async function setupScheduledBackups() {
|
|
||||||
// Start scheduled backups (reads schedule from database config)
|
|
||||||
await startScheduledBackups();
|
|
||||||
console.log('Scheduled backups started');
|
|
||||||
|
|
||||||
// Later, if needed, stop scheduled backups
|
|
||||||
// stopScheduledBackups();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example 8: Admin API endpoints available
|
|
||||||
/*
|
|
||||||
GET /api/admin/database-backup/status - Get backup status and config
|
|
||||||
PUT /api/admin/database-backup/config - Update backup configuration
|
|
||||||
POST /api/admin/database-backup/backup - Trigger manual backup
|
|
||||||
GET /api/admin/database-backup/progress - Get current backup progress
|
|
||||||
GET /api/admin/database-backup/history - Get backup history with pagination
|
|
||||||
DELETE /api/admin/database-backup/cleanup - Delete old backup files
|
|
||||||
POST /api/admin/database-backup/test - Test backup configuration
|
|
||||||
GET /api/admin/database-backup/checksums - Get current table checksums
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Example 9: Configuration options stored in database
|
|
||||||
/*
|
|
||||||
database_backup_enabled: boolean - Enable/disable scheduled backups
|
|
||||||
database_backup_schedule: string - Cron schedule (default: '0 3 * * *')
|
|
||||||
database_backup_destination_path: string - Where to store backups
|
|
||||||
database_backup_compress: boolean - Enable gzip compression
|
|
||||||
database_backup_validate_integrity: boolean - Validate after backup
|
|
||||||
database_backup_include_checksums: boolean - Calculate table checksums
|
|
||||||
database_backup_retention_days: number - Days to keep old backups
|
|
||||||
database_backup_email_on_failure: boolean - Send email on failure
|
|
||||||
database_backup_email_on_success: boolean - Send email on success
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Example 10: Production considerations
|
|
||||||
/*
|
|
||||||
1. Ensure destination path has sufficient space
|
|
||||||
2. For large databases, backups may take significant time
|
|
||||||
3. PostgreSQL backups use single-transaction mode by default
|
|
||||||
4. Compression typically reduces size by 70-90%
|
|
||||||
5. Schedule backups during low-traffic periods
|
|
||||||
6. Monitor backup history for failures
|
|
||||||
7. Test restore procedures regularly
|
|
||||||
8. Consider replication for real-time redundancy
|
|
||||||
*/
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
manualBackup,
|
|
||||||
customBackup,
|
|
||||||
backupWithProgress,
|
|
||||||
getBackupHistory,
|
|
||||||
cleanupBackups,
|
|
||||||
getTableChecksums,
|
|
||||||
setupScheduledBackups
|
|
||||||
};
|
|
||||||
@@ -26,7 +26,7 @@ const { getAppSetting } = require('../utils/appSettings');
|
|||||||
const { cleanNetMinor } = require('../utils/invoiceRounding');
|
const { cleanNetMinor } = require('../utils/invoiceRounding');
|
||||||
const { AppError } = require('../utils/errors');
|
const { AppError } = require('../utils/errors');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { claimNextSequence } = require('../utils/documentSequences');
|
const { nextDocumentNumber } = require('../utils/documentSequences');
|
||||||
const { formatShortDate } = require('../utils/dateFormatter');
|
const { formatShortDate } = require('../utils/dateFormatter');
|
||||||
const businessProfileService = require('./businessProfileService');
|
const businessProfileService = require('./businessProfileService');
|
||||||
const { buildIssuerBlock, buildRecipientBlock } = require('./_renderContext');
|
const { buildIssuerBlock, buildRecipientBlock } = require('./_renderContext');
|
||||||
@@ -46,26 +46,13 @@ function getHierarchyHelpers() {
|
|||||||
const { ensureInt, ensureNumber } = require('../utils/numericHelpers');
|
const { ensureInt, ensureNumber } = require('../utils/numericHelpers');
|
||||||
const { hasColumnCached } = require('../utils/schemaCache');
|
const { hasColumnCached } = require('../utils/schemaCache');
|
||||||
|
|
||||||
function formatNumberInTemplate(format, year, seq) {
|
|
||||||
return format
|
|
||||||
.replace(/\{YEAR\}/g, String(year))
|
|
||||||
.replace(/\{MONTH\}/g, String(new Date().getMonth() + 1).padStart(2, '0'))
|
|
||||||
.replace(/\{SEQ:(\d+)d\}/g, (_, pad) => String(seq).padStart(parseInt(pad, 10), '0'))
|
|
||||||
.replace(/\{SEQ\}/g, String(seq));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Atomic gap-free invoice number generator. See utils/documentSequences.js
|
// Atomic gap-free invoice number generator. See utils/documentSequences.js
|
||||||
// for the locking story; migration 132 created the underlying table.
|
// for the locking story; migration 132 created the underlying table.
|
||||||
// The previous SELECT-MAX-then-INSERT path raced under concurrent
|
// The previous SELECT-MAX-then-INSERT path raced under concurrent
|
||||||
// admin creates and emitted a random `R-2026-AB12C3` after 5 retries,
|
// admin creates and emitted a random `R-2026-AB12C3` after 5 retries,
|
||||||
// breaking the §14 UStG single-sequence requirement.
|
// breaking the §14 UStG single-sequence requirement.
|
||||||
async function nextInvoiceNumber(trx) {
|
async function nextInvoiceNumber(trx) {
|
||||||
// Read through `trx` when present — getAppSetting on the global db inside an
|
return nextDocumentNumber('invoice', 'crm_invoices_number_format', 'R-{YEAR}-{SEQ:04d}', trx);
|
||||||
// open transaction deadlocks the single-connection SQLite pool.
|
|
||||||
const format = (await getAppSetting('crm_invoices_number_format', null, trx || db)) || 'R-{YEAR}-{SEQ:04d}';
|
|
||||||
const year = new Date().getFullYear();
|
|
||||||
const seq = await claimNextSequence('invoice', year, trx);
|
|
||||||
return formatNumberInTemplate(format, year, seq);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureCustomerCanBill(customer) {
|
function ensureCustomerCanBill(customer) {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const { getAppSetting } = require('../utils/appSettings');
|
|||||||
const { cleanNetMinor } = require('../utils/invoiceRounding');
|
const { cleanNetMinor } = require('../utils/invoiceRounding');
|
||||||
const { AppError } = require('../utils/errors');
|
const { AppError } = require('../utils/errors');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { claimNextSequence } = require('../utils/documentSequences');
|
const { nextDocumentNumber } = require('../utils/documentSequences');
|
||||||
const { formatShortDate } = require('../utils/dateFormatter');
|
const { formatShortDate } = require('../utils/dateFormatter');
|
||||||
const businessProfileService = require('./businessProfileService');
|
const businessProfileService = require('./businessProfileService');
|
||||||
const { buildIssuerBlock, buildRecipientBlock } = require('./_renderContext');
|
const { buildIssuerBlock, buildRecipientBlock } = require('./_renderContext');
|
||||||
@@ -300,28 +300,12 @@ async function insertLineItemsHierarchical(trx, tableName, ownerColumn, ownerId,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatNumberInTemplate(format, year, seq) {
|
|
||||||
// Tokens: {YEAR}, {MONTH}, {SEQ:04d}. Defaults handle padding via
|
|
||||||
// a tiny formatter, kept inline to avoid a new dependency.
|
|
||||||
return format
|
|
||||||
.replace(/\{YEAR\}/g, String(year))
|
|
||||||
.replace(/\{MONTH\}/g, String(new Date().getMonth() + 1).padStart(2, '0'))
|
|
||||||
.replace(/\{SEQ:(\d+)d\}/g, (_, pad) => String(seq).padStart(parseInt(pad, 10), '0'))
|
|
||||||
.replace(/\{SEQ\}/g, String(seq));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Atomic gap-free quote number generator. See utils/documentSequences.js
|
// Atomic gap-free quote number generator. See utils/documentSequences.js
|
||||||
// for the locking story; migration 132 created the underlying table.
|
// for the locking story; migration 132 created the underlying table.
|
||||||
// The previous SELECT-MAX-then-INSERT path raced under concurrent
|
// The previous SELECT-MAX-then-INSERT path raced under concurrent
|
||||||
// admin creates and could emit `Q-2026-AB12C3` after 5 retries.
|
// admin creates and could emit `Q-2026-AB12C3` after 5 retries.
|
||||||
async function nextQuoteNumber(trx) {
|
async function nextQuoteNumber(trx) {
|
||||||
// Read through `trx` when present — getAppSetting on the global db inside an
|
return nextDocumentNumber('quote', 'crm_quotes_number_format', 'Q-{YEAR}-{SEQ:04d}', trx);
|
||||||
// open transaction deadlocks the single-connection SQLite pool (prepare_quote
|
|
||||||
// runs createQuote unattended from a workflow).
|
|
||||||
const format = (await getAppSetting('crm_quotes_number_format', null, trx || db)) || 'Q-{YEAR}-{SEQ:04d}';
|
|
||||||
const year = new Date().getFullYear();
|
|
||||||
const seq = await claimNextSequence('quote', year, trx);
|
|
||||||
return formatNumberInTemplate(format, year, seq);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const backupManifest = require('./backupManifest');
|
|||||||
const S3StorageAdapter = require('./storage/s3Storage');
|
const S3StorageAdapter = require('./storage/s3Storage');
|
||||||
const { queueEmail } = require('./emailProcessor');
|
const { queueEmail } = require('./emailProcessor');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
|
const { formatBytes } = require('../utils/formatBytes');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1528,15 +1529,7 @@ END $$;`
|
|||||||
* Format bytes to human readable
|
* Format bytes to human readable
|
||||||
*/
|
*/
|
||||||
formatBytes(bytes, decimals = 2) {
|
formatBytes(bytes, decimals = 2) {
|
||||||
if (bytes === 0) return '0 Bytes';
|
return formatBytes(bytes, decimals);
|
||||||
|
|
||||||
const k = 1024;
|
|
||||||
const dm = decimals < 0 ? 0 : decimals;
|
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
||||||
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,221 +0,0 @@
|
|||||||
/**
|
|
||||||
* Example usage of the S3StorageAdapter
|
|
||||||
*
|
|
||||||
* This file demonstrates how to use the S3 storage adapter for various operations
|
|
||||||
*/
|
|
||||||
|
|
||||||
const S3StorageAdapter = require('./s3Storage');
|
|
||||||
|
|
||||||
// Example 1: Basic AWS S3 Configuration
|
|
||||||
const s3Storage = new S3StorageAdapter({
|
|
||||||
bucket: 'my-photo-bucket',
|
|
||||||
region: 'us-east-1',
|
|
||||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
|
||||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
|
|
||||||
});
|
|
||||||
|
|
||||||
// Example 2: MinIO Configuration (S3-compatible)
|
|
||||||
const minioStorage = new S3StorageAdapter({
|
|
||||||
bucket: 'photo-storage',
|
|
||||||
endpoint: 'http://localhost:9000', // MinIO endpoint
|
|
||||||
accessKeyId: 'minioadmin',
|
|
||||||
secretAccessKey: 'minioadmin',
|
|
||||||
forcePathStyle: true, // Required for MinIO
|
|
||||||
sslEnabled: false // For local development
|
|
||||||
});
|
|
||||||
|
|
||||||
// Example 3: DigitalOcean Spaces Configuration
|
|
||||||
const spacesStorage = new S3StorageAdapter({
|
|
||||||
bucket: 'my-space-name',
|
|
||||||
endpoint: 'https://nyc3.digitaloceanspaces.com',
|
|
||||||
region: 'nyc3',
|
|
||||||
accessKeyId: process.env.DO_SPACES_KEY,
|
|
||||||
secretAccessKey: process.env.DO_SPACES_SECRET
|
|
||||||
});
|
|
||||||
|
|
||||||
// Usage Examples
|
|
||||||
async function examples() {
|
|
||||||
try {
|
|
||||||
// Test connection
|
|
||||||
await s3Storage.testConnection();
|
|
||||||
console.log('Connection successful!');
|
|
||||||
|
|
||||||
// Upload a file with progress tracking
|
|
||||||
const uploadResult = await s3Storage.upload(
|
|
||||||
'/path/to/local/photo.jpg',
|
|
||||||
'events/wedding-2024/photo.jpg',
|
|
||||||
{
|
|
||||||
contentType: 'image/jpeg',
|
|
||||||
metadata: {
|
|
||||||
event: 'wedding-2024',
|
|
||||||
photographer: 'John Doe'
|
|
||||||
},
|
|
||||||
onProgress: (loaded, total) => {
|
|
||||||
const percentage = Math.round((loaded / total) * 100);
|
|
||||||
console.log(`Upload progress: ${percentage}%`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
console.log('Upload complete:', uploadResult.Location);
|
|
||||||
|
|
||||||
// Upload from stream
|
|
||||||
const readStream = fs.createReadStream('/path/to/large-video.mp4');
|
|
||||||
await s3Storage.uploadStream(readStream, 'events/wedding-2024/video.mp4', {
|
|
||||||
contentType: 'video/mp4',
|
|
||||||
onProgress: (loaded, total) => {
|
|
||||||
console.log(`Streamed ${loaded} of ${total} bytes`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Download a file
|
|
||||||
await s3Storage.download(
|
|
||||||
'events/wedding-2024/photo.jpg',
|
|
||||||
'/path/to/downloaded/photo.jpg',
|
|
||||||
{
|
|
||||||
onProgress: (loaded, total) => {
|
|
||||||
const percentage = Math.round((loaded / total) * 100);
|
|
||||||
console.log(`Download progress: ${percentage}%`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get a download stream
|
|
||||||
const downloadStream = await s3Storage.downloadStream('events/wedding-2024/photo.jpg');
|
|
||||||
downloadStream.pipe(fs.createWriteStream('/path/to/output.jpg'));
|
|
||||||
|
|
||||||
// List files
|
|
||||||
const listing = await s3Storage.list('events/wedding-2024/');
|
|
||||||
console.log(`Found ${listing.Contents.length} files`);
|
|
||||||
listing.Contents.forEach(file => {
|
|
||||||
console.log(`- ${file.Key} (${file.Size} bytes)`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Generate pre-signed URL for temporary access
|
|
||||||
const downloadUrl = await s3Storage.getSignedUrl('getObject', 'events/wedding-2024/photo.jpg', {
|
|
||||||
expiresIn: 3600 // 1 hour
|
|
||||||
});
|
|
||||||
console.log('Pre-signed download URL:', downloadUrl);
|
|
||||||
|
|
||||||
// Generate pre-signed upload URL
|
|
||||||
const uploadUrl = await s3Storage.getSignedUrl('putObject', 'events/wedding-2024/new-photo.jpg', {
|
|
||||||
expiresIn: 1800, // 30 minutes
|
|
||||||
params: {
|
|
||||||
ContentType: 'image/jpeg'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
console.log('Pre-signed upload URL:', uploadUrl);
|
|
||||||
|
|
||||||
// Check if file exists
|
|
||||||
const exists = await s3Storage.exists('events/wedding-2024/photo.jpg');
|
|
||||||
console.log('File exists:', exists);
|
|
||||||
|
|
||||||
// Get metadata
|
|
||||||
const metadata = await s3Storage.getMetadata('events/wedding-2024/photo.jpg');
|
|
||||||
console.log('File metadata:', metadata);
|
|
||||||
|
|
||||||
// Copy file
|
|
||||||
await s3Storage.copy(
|
|
||||||
'events/wedding-2024/photo.jpg',
|
|
||||||
'events/wedding-2024/photo-copy.jpg'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Move file
|
|
||||||
await s3Storage.move(
|
|
||||||
'events/wedding-2024/photo-copy.jpg',
|
|
||||||
'events/wedding-2024/archived/photo.jpg'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Delete file
|
|
||||||
await s3Storage.delete('events/wedding-2024/temp-photo.jpg');
|
|
||||||
|
|
||||||
// Delete multiple files
|
|
||||||
const deleteResult = await s3Storage.deleteMany([
|
|
||||||
'events/wedding-2024/temp1.jpg',
|
|
||||||
'events/wedding-2024/temp2.jpg',
|
|
||||||
'events/wedding-2024/temp3.jpg'
|
|
||||||
]);
|
|
||||||
console.log(`Deleted ${deleteResult.Deleted.length} files`);
|
|
||||||
|
|
||||||
// Get storage statistics
|
|
||||||
const stats = await s3Storage.getStats('events/');
|
|
||||||
console.log(`Total files: ${stats.totalCount}`);
|
|
||||||
console.log(`Total size: ${stats.totalSizeFormatted}`);
|
|
||||||
|
|
||||||
// Listen to events
|
|
||||||
s3Storage.on('uploadProgress', (data) => {
|
|
||||||
console.log(`Uploading ${data.key}: ${data.loaded}/${data.total}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
s3Storage.on('uploadComplete', (data) => {
|
|
||||||
console.log(`Upload completed: ${data.key}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
s3Storage.on('uploadError', (data) => {
|
|
||||||
console.error(`Upload failed for ${data.key}:`, data.error);
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Integration with existing photo upload workflow
|
|
||||||
async function integrateWithPhotoUpload(eventId, files) {
|
|
||||||
const storage = new S3StorageAdapter({
|
|
||||||
bucket: process.env.S3_BUCKET,
|
|
||||||
region: process.env.AWS_REGION,
|
|
||||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
|
||||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
|
|
||||||
});
|
|
||||||
|
|
||||||
const uploadedPhotos = [];
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
try {
|
|
||||||
// Generate unique S3 key
|
|
||||||
const s3Key = storage.generateKey(file.originalname, `events/${eventId}`);
|
|
||||||
|
|
||||||
// Upload to S3
|
|
||||||
const result = await storage.upload(file.path, s3Key, {
|
|
||||||
contentType: file.mimetype,
|
|
||||||
metadata: {
|
|
||||||
eventId: eventId,
|
|
||||||
originalName: file.originalname,
|
|
||||||
uploadedAt: new Date().toISOString()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
uploadedPhotos.push({
|
|
||||||
filename: s3Key,
|
|
||||||
originalName: file.originalname,
|
|
||||||
size: file.size,
|
|
||||||
mimeType: file.mimetype,
|
|
||||||
s3Location: result.Location,
|
|
||||||
s3Key: s3Key
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clean up local temp file
|
|
||||||
await fs.promises.unlink(file.path);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to upload ${file.originalname}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return uploadedPhotos;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Environment variables needed:
|
|
||||||
// AWS_ACCESS_KEY_ID=your-access-key
|
|
||||||
// AWS_SECRET_ACCESS_KEY=your-secret-key
|
|
||||||
// AWS_REGION=us-east-1
|
|
||||||
// S3_BUCKET=your-bucket-name
|
|
||||||
|
|
||||||
// For MinIO:
|
|
||||||
// MINIO_ENDPOINT=http://localhost:9000
|
|
||||||
// MINIO_ACCESS_KEY=minioadmin
|
|
||||||
// MINIO_SECRET_KEY=minioadmin
|
|
||||||
// MINIO_BUCKET=photo-storage
|
|
||||||
|
|
||||||
module.exports = { examples, integrateWithPhotoUpload };
|
|
||||||
@@ -7,6 +7,7 @@ const path = require('path');
|
|||||||
const stream = require('stream');
|
const stream = require('stream');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const logger = require('../../utils/logger');
|
const logger = require('../../utils/logger');
|
||||||
|
const { formatBytes } = require('../../utils/formatBytes');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* S3 Storage Adapter for handling file uploads to S3 and S3-compatible services
|
* S3 Storage Adapter for handling file uploads to S3 and S3-compatible services
|
||||||
@@ -738,15 +739,7 @@ class S3StorageAdapter extends stream.EventEmitter {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
_formatBytes(bytes, decimals = 2) {
|
_formatBytes(bytes, decimals = 2) {
|
||||||
if (bytes === 0) return '0 Bytes';
|
return formatBytes(bytes, decimals);
|
||||||
|
|
||||||
const k = 1024;
|
|
||||||
const dm = decimals < 0 ? 0 : decimals;
|
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
||||||
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,34 @@
|
|||||||
|
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { AppError } = require('./errors');
|
const { AppError } = require('./errors');
|
||||||
|
const { getAppSetting } = require('./appSettings');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a document-number format template.
|
||||||
|
* Tokens: {YEAR}, {MONTH}, {SEQ:04d} (zero-padded), {SEQ}.
|
||||||
|
*/
|
||||||
|
function formatNumberInTemplate(format, year, seq) {
|
||||||
|
return format
|
||||||
|
.replace(/\{YEAR\}/g, String(year))
|
||||||
|
.replace(/\{MONTH\}/g, String(new Date().getMonth() + 1).padStart(2, '0'))
|
||||||
|
.replace(/\{SEQ:(\d+)d\}/g, (_, pad) => String(seq).padStart(parseInt(pad, 10), '0'))
|
||||||
|
.replace(/\{SEQ\}/g, String(seq));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim + format the next document number for `kind` in the current
|
||||||
|
* year, using the admin-configurable format stored under `settingKey`.
|
||||||
|
*
|
||||||
|
* Reads the setting through `trx` when present — getAppSetting on the
|
||||||
|
* global db inside an open transaction deadlocks the single-connection
|
||||||
|
* SQLite pool.
|
||||||
|
*/
|
||||||
|
async function nextDocumentNumber(kind, settingKey, defaultFormat, trx) {
|
||||||
|
const format = (await getAppSetting(settingKey, null, trx || db)) || defaultFormat;
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const seq = await claimNextSequence(kind, year, trx);
|
||||||
|
return formatNumberInTemplate(format, year, seq);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Claim the next sequence value for (kind, year). Returns the new
|
* Claim the next sequence value for (kind, year). Returns the new
|
||||||
@@ -90,4 +118,4 @@ async function claimNextSequence(kind, year, trx) {
|
|||||||
return await db.transaction(async (innerTrx) => exec(innerTrx));
|
return await db.transaction(async (innerTrx) => exec(innerTrx));
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { claimNextSequence };
|
module.exports = { claimNextSequence, formatNumberInTemplate, nextDocumentNumber };
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Human-readable byte size, e.g. 1536 -> "1.5 KB".
|
||||||
|
* Falsy/undefined input returns '0 Bytes'.
|
||||||
|
*/
|
||||||
|
function formatBytes(bytes, decimals = 2) {
|
||||||
|
if (!bytes) {
|
||||||
|
return '0 Bytes';
|
||||||
|
}
|
||||||
|
|
||||||
|
const k = 1024;
|
||||||
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { formatBytes };
|
||||||
@@ -1,342 +0,0 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
|
||||||
import { Palette, RotateCcw, Check, Upload } from 'lucide-react';
|
|
||||||
import { Button, Card, Input } from '../common';
|
|
||||||
import { GALLERY_THEME_PRESETS, type ThemeConfig } from '../../contexts/ThemeContext';
|
|
||||||
import { settingsService } from '../../services/settings.service';
|
|
||||||
import { toast } from 'react-toastify';
|
|
||||||
import { buildResourceUrl } from '../../utils/url';
|
|
||||||
|
|
||||||
interface ThemeCustomizerProps {
|
|
||||||
value: ThemeConfig;
|
|
||||||
onChange: (theme: ThemeConfig) => void;
|
|
||||||
presetName?: string;
|
|
||||||
onPresetChange?: (presetName: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
presetName = 'default',
|
|
||||||
onPresetChange
|
|
||||||
}) => {
|
|
||||||
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
|
|
||||||
const [selectedPreset, setSelectedPreset] = useState(presetName);
|
|
||||||
const [customCss, setCustomCss] = useState(value.customCss || '');
|
|
||||||
const logoInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLocalTheme(value);
|
|
||||||
setCustomCss(value.customCss || '');
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSelectedPreset(presetName);
|
|
||||||
}, [presetName]);
|
|
||||||
|
|
||||||
const handleChange = (key: keyof ThemeConfig, newValue: any) => {
|
|
||||||
const updated = { ...localTheme, [key]: newValue };
|
|
||||||
setLocalTheme(updated);
|
|
||||||
// Always propagate changes to parent, not just in preview mode
|
|
||||||
onChange(updated);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePresetSelect = (presetKey: string) => {
|
|
||||||
const preset = GALLERY_THEME_PRESETS[presetKey];
|
|
||||||
if (preset) {
|
|
||||||
setSelectedPreset(presetKey);
|
|
||||||
setLocalTheme(preset.config);
|
|
||||||
if (onPresetChange) {
|
|
||||||
onPresetChange(presetKey);
|
|
||||||
}
|
|
||||||
// Always propagate preset changes
|
|
||||||
onChange(preset.config);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleApply = () => {
|
|
||||||
const themeWithCss = { ...localTheme, customCss };
|
|
||||||
setLocalTheme(themeWithCss);
|
|
||||||
onChange(themeWithCss);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
const defaultPreset = GALLERY_THEME_PRESETS['default'];
|
|
||||||
if (defaultPreset) {
|
|
||||||
setSelectedPreset('default');
|
|
||||||
setLocalTheme(defaultPreset.config);
|
|
||||||
setCustomCss('');
|
|
||||||
onChange(defaultPreset.config);
|
|
||||||
if (onPresetChange) {
|
|
||||||
onPresetChange('default');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) {
|
|
||||||
try {
|
|
||||||
// Upload to server
|
|
||||||
const logoUrl = await settingsService.uploadLogo(file);
|
|
||||||
// Update theme with the server URL
|
|
||||||
handleChange('logoUrl', logoUrl);
|
|
||||||
toast.success('Logo uploaded successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to upload logo:', error);
|
|
||||||
toast.error('Failed to upload logo');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Preset Themes */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">Preset Themes</h3>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
|
||||||
{Object.entries(GALLERY_THEME_PRESETS).map(([key, theme]) => (
|
|
||||||
<button
|
|
||||||
key={key}
|
|
||||||
onClick={() => handlePresetSelect(key)}
|
|
||||||
className={`relative p-4 rounded-lg border-2 transition-all ${
|
|
||||||
selectedPreset === key
|
|
||||||
? 'tile-selected'
|
|
||||||
: 'border-neutral-200 hover:border-neutral-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="font-medium text-sm">{theme.name}</span>
|
|
||||||
{selectedPreset === key && (
|
|
||||||
<Check className="w-4 h-4 text-accent" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div
|
|
||||||
className="w-6 h-6 rounded-full border border-neutral-200"
|
|
||||||
style={{ backgroundColor: theme.config.primaryColor }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="w-6 h-6 rounded-full border border-neutral-200"
|
|
||||||
style={{ backgroundColor: theme.config.accentColor }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="w-6 h-6 rounded-full border border-neutral-200"
|
|
||||||
style={{ backgroundColor: theme.config.backgroundColor }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Color Customization */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">Colors</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
|
||||||
Primary Color
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={localTheme.primaryColor || '#5C8762'}
|
|
||||||
onChange={(e) => handleChange('primaryColor', e.target.value)}
|
|
||||||
className="h-10 w-20 rounded border border-neutral-300"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
value={localTheme.primaryColor || '#5C8762'}
|
|
||||||
onChange={(e) => handleChange('primaryColor', e.target.value)}
|
|
||||||
placeholder="#5C8762"
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
|
||||||
Accent Color
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={localTheme.accentColor || '#22c55e'}
|
|
||||||
onChange={(e) => handleChange('accentColor', e.target.value)}
|
|
||||||
className="h-10 w-20 rounded border border-neutral-300"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
value={localTheme.accentColor || '#22c55e'}
|
|
||||||
onChange={(e) => handleChange('accentColor', e.target.value)}
|
|
||||||
placeholder="#22c55e"
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
|
||||||
Background Color
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={localTheme.backgroundColor || '#fafafa'}
|
|
||||||
onChange={(e) => handleChange('backgroundColor', e.target.value)}
|
|
||||||
className="h-10 w-20 rounded border border-neutral-300"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
value={localTheme.backgroundColor || '#fafafa'}
|
|
||||||
onChange={(e) => handleChange('backgroundColor', e.target.value)}
|
|
||||||
placeholder="#fafafa"
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
|
||||||
Text Color
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={localTheme.textColor || '#171717'}
|
|
||||||
onChange={(e) => handleChange('textColor', e.target.value)}
|
|
||||||
className="h-10 w-20 rounded border border-neutral-300"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
value={localTheme.textColor || '#171717'}
|
|
||||||
onChange={(e) => handleChange('textColor', e.target.value)}
|
|
||||||
placeholder="#171717"
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Typography & Style */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">Typography & Style</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
|
||||||
Font Family
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={localTheme.fontFamily || 'Inter, sans-serif'}
|
|
||||||
onChange={(e) => handleChange('fontFamily', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
|
||||||
>
|
|
||||||
<option value="Inter, sans-serif">Inter (Default)</option>
|
|
||||||
<option value="Georgia, serif">Georgia (Elegant)</option>
|
|
||||||
<option value="Helvetica, Arial, sans-serif">Helvetica (Clean)</option>
|
|
||||||
<option value="'Playfair Display', serif">Playfair Display (Sophisticated)</option>
|
|
||||||
<option value="'Comic Sans MS', cursive">Comic Sans (Playful)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
|
||||||
Border Radius
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{(['none', 'sm', 'md', 'lg'] as const).map((radius) => (
|
|
||||||
<button
|
|
||||||
key={radius}
|
|
||||||
onClick={() => handleChange('borderRadius', radius)}
|
|
||||||
className={`px-4 py-2 rounded-lg border-2 transition-all ${
|
|
||||||
localTheme.borderRadius === radius
|
|
||||||
? 'tile-selected'
|
|
||||||
: 'border-neutral-200 hover:border-neutral-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{radius === 'none' ? 'None' : radius.toUpperCase()}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Logo Upload */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">Branding</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
|
||||||
Custom Logo
|
|
||||||
</label>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
{localTheme.logoUrl && (
|
|
||||||
<img
|
|
||||||
src={localTheme.logoUrl.startsWith('http') ? localTheme.logoUrl : buildResourceUrl(localTheme.logoUrl)}
|
|
||||||
alt="Custom logo"
|
|
||||||
className="h-16 w-auto object-contain"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<input
|
|
||||||
ref={logoInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handleLogoUpload}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => logoInputRef.current?.click()}
|
|
||||||
leftIcon={<Upload className="w-4 h-4" />}
|
|
||||||
>
|
|
||||||
Upload Logo
|
|
||||||
</Button>
|
|
||||||
{localTheme.logoUrl && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleChange('logoUrl', '')}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Custom CSS */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">Custom CSS</h3>
|
|
||||||
<textarea
|
|
||||||
value={customCss}
|
|
||||||
onChange={(e) => setCustomCss(e.target.value)}
|
|
||||||
placeholder="/* Add custom CSS here */"
|
|
||||||
className="w-full h-32 px-3 py-2 font-mono text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
|
||||||
/>
|
|
||||||
<p className="mt-2 text-sm text-neutral-600">
|
|
||||||
Advanced: Add custom CSS to further customize the appearance
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center justify-end gap-3">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
leftIcon={<RotateCcw className="w-4 h-4" />}
|
|
||||||
onClick={handleReset}
|
|
||||||
>
|
|
||||||
Reset to Default
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
leftIcon={<Palette className="w-4 h-4" />}
|
|
||||||
onClick={handleApply}
|
|
||||||
>
|
|
||||||
Apply Theme
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
export { AdminLayout } from './AdminLayout';
|
export { AdminLayout } from './AdminLayout';
|
||||||
export { AdminSidebar } from './AdminSidebar';
|
export { AdminSidebar } from './AdminSidebar';
|
||||||
export { AdminHeader } from './AdminHeader';
|
export { AdminHeader } from './AdminHeader';
|
||||||
export { ThemeCustomizer } from './ThemeCustomizer';
|
|
||||||
export { PasswordChangeModal } from './PasswordChangeModal';
|
export { PasswordChangeModal } from './PasswordChangeModal';
|
||||||
export { AdminAuthWrapper } from './AdminAuthWrapper';
|
export { AdminAuthWrapper } from './AdminAuthWrapper';
|
||||||
export { PhotoUpload } from './PhotoUpload';
|
export { PhotoUpload } from './PhotoUpload';
|
||||||
|
|||||||
Reference in New Issue
Block a user