UNPKG

@ideal-photography/shared

Version:

Shared MongoDB and utility logic for Ideal Photography PWAs: users, products, services, bookings, orders/cart, galleries, reviews, notifications, campaigns, settings, audit logs, minimart items/orders, and push notification subscriptions.

567 lines (475 loc) 19.1 kB
import mongoose from 'mongoose'; import bcrypt from 'bcryptjs'; import crypto from 'crypto'; import { PERMISSIONS, getPermissionsForRole } from '../constants/permissions.js'; const adminSchema = new mongoose.Schema({ username: { type: String, required: [true, 'Username is required'], unique: true, trim: true, minlength: [3, 'Username must be at least 3 characters'] }, password: { type: String, required: [true, 'Password is required'], minlength: [6, 'Password must be at least 6 characters'] }, role: { type: String, enum: ['admin', 'manager', 'super_admin'], default: 'admin', required: true }, permissions: { type: [String], default: function () { // Auto-assign permissions based on role return getPermissionsForRole(this.role); }, validate: { validator: function (permissions) { // Validate that all permissions are valid const allValidPermissions = Object.values(PERMISSIONS).reduce((acc, category) => { return acc.concat(Object.values(category)); }, []); return permissions.every(permission => allValidPermissions.includes(permission)); }, message: 'Invalid permission provided' } }, isActive: { type: Boolean, default: true }, isVerified: { type: Boolean, default: false }, verifiedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'Admin' }, verifiedAt: { type: Date }, loginAttempts: { type: Number, default: 0 }, lockUntil: { type: Date }, lastLogin: { type: Date }, createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'Admin' }, // Session management sessions: { type: [{ deviceId: { type: String, required: true, validate: { validator: function (v) { return v && v.length > 0; }, message: 'Device ID is required' } }, deviceName: { type: String, default: 'Unknown Device' }, platform: { type: String, default: 'Unknown' }, browser: { type: String, default: 'Unknown' }, ipAddress: { type: String, default: 'Unknown' }, userAgent: { type: String, default: 'Unknown' }, lastActive: { type: Date, default: Date.now }, isActive: { type: Boolean, default: true } }], default: [], validate: { validator: function (sessions) { if (!Array.isArray(sessions)) return false; return sessions.every(s => s && s.deviceId); }, message: 'All sessions must have valid device IDs' } }, // Permission audit trail permissionHistory: [{ permissions: [String], changedBy: { type: mongoose.Schema.Types.ObjectId, ref: 'Admin' }, changedAt: { type: Date, default: Date.now }, reason: String, ipAddress: String }] }, { timestamps: true }); // Virtual for full name adminSchema.virtual('fullName').get(function () { return this.username; }); // Virtual for isLocked adminSchema.virtual('isLocked').get(function () { return !!(this.lockUntil && this.lockUntil > Date.now()); }); // Indexes // Note: username already has unique index from schema definition adminSchema.index({ role: 1 }); adminSchema.index({ isActive: 1 }); adminSchema.index({ isVerified: 1 }); adminSchema.index({ 'sessions.deviceId': 1 }); // Pre-save middleware adminSchema.pre('save', async function (next) { // Hash password if modified if (this.isModified('password')) { this.password = await bcrypt.hash(this.password, 12); } // Always ensure permissions are correctly assigned based on role // This handles both new admin creation and role changes const correctPermissions = getPermissionsForRole(this.role); // If permissions are empty, incorrect, or role changed, update them if (!this.permissions || this.permissions.length === 0 || this.isModified('role') || !this.hasAllPermissions(correctPermissions)) { console.log(`🔄 Setting correct permissions for ${this.role} ${this.username}: ${correctPermissions.length} permissions`); this.permissions = correctPermissions; } // Ensure sessions are always valid if (this.isModified('sessions')) { if (!this.sessions || !Array.isArray(this.sessions)) { this.sessions = []; } else { // Filter out invalid sessions this.sessions = this.sessions.filter(s => s && typeof s === 'object' && s.deviceId); } } next(); }); // Pre-update middleware for permission changes adminSchema.pre('findOneAndUpdate', function (next) { const update = this.getUpdate(); // If permissions are being updated, validate them if (update.permissions) { const allValidPermissions = Object.values(PERMISSIONS).reduce((acc, category) => { return acc.concat(Object.values(category)); }, []); const isValid = update.permissions.every(permission => allValidPermissions.includes(permission)); if (!isValid) { return next(new Error('Invalid permission provided')); } } next(); }); // Instance methods adminSchema.methods.comparePassword = function (candidatePassword) { return bcrypt.compare(candidatePassword, this.password); }; adminSchema.methods.incLoginAttempts = function () { if (this.lockUntil && this.lockUntil < Date.now()) { return this.updateOne({ $unset: { lockUntil: 1 }, $set: { loginAttempts: 1 } }); } const updates = { $inc: { loginAttempts: 1 } }; if (this.loginAttempts + 1 >= 5 && !this.isLocked) { updates.$set = { lockUntil: Date.now() + 2 * 60 * 60 * 1000 }; } return this.updateOne(updates); }; adminSchema.methods.resetLoginAttempts = function () { return this.updateOne({ $unset: { loginAttempts: 1, lockUntil: 1 } }); }; // Permission management methods adminSchema.methods.hasPermission = function (permission) { return this.permissions.includes(permission); }; adminSchema.methods.hasAnyPermission = function (permissions) { return permissions.some(permission => this.permissions.includes(permission)); }; adminSchema.methods.hasAllPermissions = function (permissions) { return permissions.every(permission => this.permissions.includes(permission)); }; // Sync permissions with role (useful for fixing outdated permissions) adminSchema.methods.syncPermissions = function () { const correctPermissions = getPermissionsForRole(this.role); const missingPermissions = correctPermissions.filter(perm => !this.permissions.includes(perm)); if (missingPermissions.length > 0) { console.log(`🔄 Syncing permissions for ${this.role} ${this.username}: adding ${missingPermissions.length} missing permissions`); this.permissions = correctPermissions; return { synced: true, added: missingPermissions.length, permissions: correctPermissions }; } return { synced: false, added: 0, permissions: this.permissions }; }; adminSchema.methods.canManageRole = function (targetRole) { // Super admin can manage anyone if (this.role === 'super_admin') return true; // Manager can manage admin and other managers if (this.role === 'manager' && ['admin', 'manager'].includes(targetRole)) return true; // Admin can only manage other admins if (this.role === 'admin' && targetRole === 'admin') return true; return false; }; adminSchema.methods.canManageAdmin = function (targetAdmin) { // Can't manage yourself if (this._id.equals(targetAdmin._id)) return false; return this.canManageRole(targetAdmin.role); }; // Static methods for permission management adminSchema.statics.syncAllPermissions = async function () { const { getPermissionsForRole } = await import('../../constants/permissions.js'); const admins = await this.find({}); let updatedCount = 0; for (const admin of admins) { const correctPermissions = getPermissionsForRole(admin.role); const missingPermissions = correctPermissions.filter(perm => !admin.permissions.includes(perm)); if (missingPermissions.length > 0) { console.log(`🔄 Syncing permissions for ${admin.role} ${admin.username}: adding ${missingPermissions.length} missing permissions`); admin.permissions = correctPermissions; await admin.save(); updatedCount++; } } return { updatedCount, totalAdmins: admins.length }; }; // Session management methods adminSchema.methods.addSession = function (sessionData) { // Initialize sessions array if it doesn't exist if (!this.sessions) { this.sessions = []; } // Validate session data if (!sessionData || typeof sessionData !== 'object') { throw new Error('Invalid session data provided'); } // Check for duplicate deviceId const existingSession = this.sessions.find(s => s && s.deviceId === sessionData.deviceId); if (existingSession) { // Update existing session instead of creating duplicate existingSession.deviceName = sessionData.deviceName || existingSession.deviceName; existingSession.platform = sessionData.platform || existingSession.platform; existingSession.browser = sessionData.browser || existingSession.browser; existingSession.ipAddress = sessionData.ipAddress || existingSession.ipAddress; existingSession.userAgent = sessionData.userAgent || existingSession.userAgent; existingSession.lastActive = new Date(); existingSession.isActive = true; return this.save(); } const session = { deviceId: sessionData.deviceId || crypto.randomBytes(16).toString('hex'), deviceName: sessionData.deviceName || 'Unknown Device', platform: sessionData.platform || 'Unknown', browser: sessionData.browser || 'Unknown', ipAddress: sessionData.ipAddress || 'Unknown', userAgent: sessionData.userAgent || 'Unknown', lastActive: new Date(), isActive: true }; this.sessions.push(session); return this.save(); }; // Method to safely add multiple sessions (useful for migration) adminSchema.methods.addSessions = function (sessionsData) { if (!this.sessions) { this.sessions = []; } if (!Array.isArray(sessionsData)) { throw new Error('Sessions data must be an array'); } let addedCount = 0; let updatedCount = 0; for (const sessionData of sessionsData) { if (!sessionData || typeof sessionData !== 'object' || !sessionData.deviceId) { continue; // Skip invalid sessions } const existingIndex = this.sessions.findIndex(s => s && s.deviceId === sessionData.deviceId); if (existingIndex >= 0) { // Update existing session this.sessions[existingIndex] = { ...this.sessions[existingIndex], ...sessionData, lastActive: sessionData.lastActive || new Date(), isActive: sessionData.isActive !== false }; updatedCount++; } else { // Add new session const session = { deviceId: sessionData.deviceId, deviceName: sessionData.deviceName || 'Unknown Device', platform: sessionData.platform || 'Unknown', browser: sessionData.browser || 'Unknown', ipAddress: sessionData.ipAddress || 'Unknown', userAgent: sessionData.userAgent || 'Unknown', lastActive: sessionData.lastActive || new Date(), isActive: sessionData.isActive !== false }; this.sessions.push(session); addedCount++; } } if (addedCount > 0 || updatedCount > 0) { return this.save().then(() => ({ added: addedCount, updated: updatedCount })); } return Promise.resolve({ added: 0, updated: 0 }); }; adminSchema.methods.removeSession = function (deviceId) { if (!this.sessions) { this.sessions = []; return this.save(); } // Validate deviceId if (!deviceId || typeof deviceId !== 'string') { throw new Error('Valid device ID is required'); } const originalLength = this.sessions.length; this.sessions = this.sessions.filter(session => session && session.deviceId !== deviceId); // Only save if something actually changed if (this.sessions.length !== originalLength) { return this.save(); } return Promise.resolve(); }; adminSchema.methods.updateSessionActivity = function (deviceId) { if (!this.sessions) { return Promise.resolve(); } // Validate deviceId if (!deviceId || typeof deviceId !== 'string') { return Promise.resolve(); } const session = this.sessions.find(s => s && s.deviceId === deviceId); if (session) { const oldLastActive = session.lastActive; session.lastActive = new Date(); // Only save if the date actually changed (avoid unnecessary saves) if (oldLastActive.getTime() !== session.lastActive.getTime()) { return this.save(); } } return Promise.resolve(); }; // Safe method to get sessions adminSchema.methods.getSessions = function () { if (!this.sessions || !Array.isArray(this.sessions)) { return []; } // Filter out any invalid session objects return this.sessions.filter(s => s && typeof s === 'object' && s.deviceId); }; // Safe method to get active sessions adminSchema.methods.getActiveSessions = function () { const sessions = this.getSessions(); return sessions.filter(s => s && s.isActive); }; // Safe method to get a specific session by deviceId adminSchema.methods.getSession = function (deviceId) { if (!deviceId || typeof deviceId !== 'string') { return null; } const sessions = this.getSessions(); return sessions.find(s => s && s.deviceId === deviceId) || null; }; // Safe method to get session count adminSchema.methods.getSessionCount = function () { return this.getSessions().length; }; // Safe method to get active session count adminSchema.methods.getActiveSessionCount = function () { return this.getActiveSessions().length; }; // Safe method to check if a session exists adminSchema.methods.hasSession = function (deviceId) { if (!deviceId || typeof deviceId !== 'string') { return false; } const sessions = this.getSessions(); return sessions.some(s => s && s.deviceId === deviceId); }; // Safe method to deactivate a session (mark as inactive instead of removing) adminSchema.methods.deactivateSession = function (deviceId) { if (!deviceId || typeof deviceId !== 'string') { throw new Error('Valid device ID is required'); } const session = this.getSession(deviceId); if (!session) { return Promise.resolve(false); // Session not found } if (session.isActive) { session.isActive = false; return this.save().then(() => true); } return Promise.resolve(false); // Already inactive }; // Safe method to reactivate a session adminSchema.methods.reactivateSession = function (deviceId) { if (!deviceId || typeof deviceId !== 'string') { throw new Error('Valid device ID is required'); } const session = this.getSession(deviceId); if (!session) { return Promise.resolve(false); // Session not found } if (!session.isActive) { session.isActive = true; session.lastActive = new Date(); return this.save().then(() => true); } return Promise.resolve(false); // Already active }; // Method to clean up expired sessions (optional: can be called periodically) adminSchema.methods.cleanupExpiredSessions = function (maxAgeHours = 24 * 7) { // Default: 7 days if (!this.sessions) { return Promise.resolve(); } const cutoffTime = new Date(Date.now() - (maxAgeHours * 60 * 60 * 1000)); const originalLength = this.sessions.length; this.sessions = this.sessions.filter(session => { if (!session || !session.lastActive) return false; return session.lastActive > cutoffTime; }); // Only save if sessions were removed if (this.sessions.length !== originalLength) { return this.save(); } return Promise.resolve(); }; // Method to get session statistics adminSchema.methods.getSessionStats = function () { const sessions = this.getSessions(); const activeSessions = this.getActiveSessions(); return { total: sessions.length, active: activeSessions.length, inactive: sessions.length - activeSessions.length, devices: [...new Set(sessions.map(s => s?.platform).filter(Boolean))], browsers: [...new Set(sessions.map(s => s?.browser).filter(Boolean))] }; }; // Method to validate and repair session data adminSchema.methods.validateAndRepairSessions = function () { if (!this.sessions || !Array.isArray(this.sessions)) { this.sessions = []; return { repaired: true, removed: 0 }; } const originalLength = this.sessions.length; const validSessions = []; for (const session of this.sessions) { if (session && typeof session === 'object' && session.deviceId) { // Ensure all required fields exist with defaults const validSession = { deviceId: session.deviceId, deviceName: session.deviceName || 'Unknown Device', platform: session.platform || 'Unknown', browser: session.browser || 'Unknown', ipAddress: session.ipAddress || 'Unknown', userAgent: session.userAgent || 'Unknown', lastActive: session.lastActive || new Date(), isActive: session.isActive !== false // Default to true if not explicitly false }; validSessions.push(validSession); } } const removed = originalLength - validSessions.length; if (removed > 0 || !Array.isArray(this.sessions)) { this.sessions = validSessions; return { repaired: true, removed }; } return { repaired: false, removed: 0 }; }; // Static methods adminSchema.statics.findByPermission = function (permission) { return this.find({ permissions: permission, isActive: true }); }; adminSchema.statics.findByRole = function (role) { return this.find({ role, isActive: true }); }; adminSchema.statics.findActiveAdmins = function () { return this.find({ isActive: true, isVerified: true }); }; // Export the model export default mongoose.model('Admin', adminSchema);