@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
JavaScript
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);