UNPKG

@ideal-photography/shared

Version:

Shared GraphQL (Apollo Server v5) and Mongoose logic for Ideal Photography PWAs: users, products, services, bookings, orders/cart, galleries, reviews, notifications, campaigns, settings, and audit logs.

803 lines (695 loc) 35.4 kB
import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import { models } from '../../mongoDB/index.js'; import { sendMail, buildVerificationEmail, buildResetPasswordEmail, buildWelcomeEmail, buildIDVerificationStatusEmail } from '../../utils/email.js'; import { sendWelcomeNotification, sendVerificationNotification, sendAdminNewUserNotification, sendAdminVerificationNotification, getActiveAdminIds, checkNotificationPreferences } from '../../utils/notifications.js'; import { QueryBuilder, queryMonitor } from '../../utils/queryOptimization.js'; import { cache, CacheKeys, CacheTTL, CacheInvalidation, withCache } from '../../utils/caching.js'; import { ResponseOptimizer, DataTransformer } from '../../utils/responseOptimization.js'; import { jobQueue } from '../../utils/backgroundJobs.js'; const userResolvers = { Query: { me: async (_, __, { user }) => { if (!user) return null; return user; }, users: withCache( (args) => CacheKeys.userList(args), CacheTTL.SHORT )(async (_, { filter = {}, page = 1, limit = 20, sortBy = "createdAt", sortOrder = "desc" }) => { const queryId = `users_${Date.now()}`; queryMonitor.startQuery(queryId, 'users_list', 'users'); try { // Use optimized query builder const pipeline = QueryBuilder.users.list(filter, { page, limit, sortBy, sortOrder: sortOrder === "desc" ? -1 : 1 }); const countPipeline = QueryBuilder.users.count(filter); const [users, countResult] = await Promise.all([ models.User.aggregate(pipeline), models.User.aggregate(countPipeline) ]); const total = countResult[0]?.total || 0; const transformedUsers = DataTransformer.transformArray(users); const result = ResponseOptimizer.paginate(transformedUsers, page, limit, total); queryMonitor.endQuery(queryId); return result; } catch (error) { queryMonitor.endQuery(queryId); throw error; } }), user: async (_, { id }) => { return await models.User.findById(id); }, userByEmail: async (_, { email }) => { return await models.User.findOne({ email: email.toLowerCase() }); }, // Admin-only analytics queries userStats: async (_, __, { user }) => { if (!user || user.constructor.modelName !== 'Admin') { throw new Error('Admin access required'); } const [ totalUsers, activeUsers, verifiedUsers, newUsersThisMonth, usersByRole ] = await Promise.all([ models.User.countDocuments(), models.User.countDocuments({ isActive: true }), models.User.countDocuments({ isEmailVerified: true }), models.User.countDocuments({ createdAt: { $gte: new Date(new Date().getFullYear(), new Date().getMonth(), 1) } }), models.User.aggregate([ { $group: { _id: '$role', count: { $sum: 1 } } }, { $project: { role: '$_id', count: 1, _id: 0 } } ]) ]); return { totalUsers, activeUsers, verifiedUsers, newUsersThisMonth, usersByRole }; }, verificationQueue: async (_, __, { user }) => { if (!user || user.constructor.modelName !== 'Admin') { throw new Error('Admin access required'); } return await models.User.find({ $or: [ { 'verification.nin.status': 'pending' }, { 'verification.driversLicense.status': 'pending' } ] }).sort({ 'verification.nin.submittedAt': -1, 'verification.driversLicense.submittedAt': -1 }); }, lockedAccounts: async (_, __, { user }) => { if (!user || user.constructor.modelName !== 'Admin') { throw new Error('Admin access required'); } return await models.User.find({ lockUntil: { $gt: new Date() } }).sort({ lockUntil: -1 }); } }, Mutation: { register: async (_, { input }, context) => { try { // Check if user already exists const existingUser = await models.User.findOne({ email: input.email.toLowerCase() }); if (existingUser) { throw new Error('User with this email already exists'); } // Validate that at least one verification document is provided if (!input.nin && !input.driversLicense) { throw new Error('Either NIN or Driver\'s License is required'); } // Create user with verification data const userData = { name: input.name, email: input.email.toLowerCase(), password: input.password, phone: input.phone, verification: { nin: { number: input.nin || null, status: input.nin ? 'pending' : 'not_submitted' }, driversLicense: { number: input.driversLicense || null, status: input.driversLicense ? 'pending' : 'not_submitted' } }, referrerInfo: input.referrerInfo }; const user = await models.User.create(userData); // Send welcome email and notifications using background jobs try { const preferences = await checkNotificationPreferences(user._id); // Queue welcome email job if (preferences.email) { jobQueue.add('send-welcome-email', { userId: user._id.toString(), email: user.email, firstName: user.name.split(' ')[0] }, { priority: 10 }); // High priority for welcome emails } // Send email verification automatically const emailToken = jwt.sign( { userId: user._id }, process.env.JWT_SECRET, { expiresIn: '24h' } ); await models.User.findByIdAndUpdate(user._id, { emailVerificationToken: emailToken, emailVerificationExpires: new Date(Date.now() + 24 * 60 * 60 * 1000) }); const baseUrl = process.env.CLIENT_URL || 'http://localhost:5173'; const verifyUrl = `${baseUrl}/email-verification?token=${encodeURIComponent(emailToken)}&email=${encodeURIComponent(user.email)}`; const { html, text } = buildVerificationEmail({ name: user.name, url: verifyUrl }); // Send verification email (high priority background job) jobQueue.add('send-email-verification', { to: user.email, subject: 'Verify your email address - IDEAS MEDIA COMPANY', html, text }, { priority: 9 }); // High priority for verification emails // Send welcome push notification (immediate for better UX) if (preferences.push) { await sendWelcomeNotification(user._id, user.name); } // Notify admins of new user registration const adminIds = await getActiveAdminIds(); if (adminIds.length > 0) { await sendAdminNewUserNotification(adminIds, user); } } catch (notificationError) { console.error('Failed to send welcome notifications:', notificationError); // Don't fail registration if notifications fail } // Generate JWT token const token = jwt.sign( { userId: user._id, aud: 'client', iss: 'ideal-photography', type: 'access' }, process.env.JWT_SECRET, { expiresIn: process.env.JWT_EXPIRES_IN || '3d' } ); const refreshToken = jwt.sign( { userId: user._id, aud: 'client', iss: 'ideal-photography', type: 'refresh' }, process.env.JWT_REFRESH_SECRET, { expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d' } ); const response = { token, refreshToken, user, expiresIn: process.env.JWT_EXPIRES_IN || '3d' }; try { await context?.audit?.('Mutation.register', {}, { status: 'success' }); } catch (_) { } return response; } catch (error) { try { await context?.audit?.('Mutation.register', {}, { status: 'failure', message: error.message }); } catch (_) { } throw new Error(error.message); } }, // Backward-compatible alias for older clients querying `registerUser` registerUser: async (_, { input }, context) => { return await userResolvers.Mutation.register(_, { input }, context); }, login: async (_, { input }, context) => { try { const user = await models.User.findOne({ email: input.email.toLowerCase() }); if (!user) { // Increment attempts for non-existent user would leak info; skip DB write throw new Error('Invalid email or password'); } const isValidPassword = await user.comparePassword(input.password); if (!isValidPassword) { await user.incLoginAttempts(); throw new Error('Invalid email or password'); } if (!user.isActive) { throw new Error('Account is deactivated'); } if (user.isLocked) { throw new Error('Account is locked due to too many failed attempts'); } // Reset login attempts on successful login await user.resetLoginAttempts(); // Update last login timestamp (on successful login only) user.lastLogin = new Date(); await user.save(); // Generate JWT token const token = jwt.sign( { userId: user._id, aud: 'client', iss: 'ideal-photography', type: 'access' }, process.env.JWT_SECRET, { expiresIn: process.env.JWT_EXPIRES_IN || '3d' } ); const refreshToken = jwt.sign( { userId: user._id, aud: 'client', iss: 'ideal-photography', type: 'refresh' }, process.env.JWT_REFRESH_SECRET, { expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d' } ); const response = { token, refreshToken, user, expiresIn: process.env.JWT_EXPIRES_IN || '3d' }; try { await context?.audit?.('Mutation.login', {}, { status: 'success' }); } catch (_) { } return response; } catch (error) { try { await context?.audit?.('Mutation.login', {}, { status: 'failure', message: error.message }); } catch (_) { } throw new Error(error.message); } }, logout: async (_, __, context) => { // Stateless JWT try { await context?.audit?.('Mutation.logout', {}, { status: 'success' }); } catch (_) { } return true; }, refreshToken: async (_, __, { req, ...context }) => { try { const headerToken = req.headers['x-refresh-token']; const bodyToken = (req.body && req.body.refreshToken) || null; const providedToken = headerToken || bodyToken; if (!providedToken) { throw new Error('Refresh token is required'); } const decoded = jwt.verify(providedToken, process.env.JWT_REFRESH_SECRET); if (decoded.aud && decoded.aud !== 'client') { throw new Error('Invalid refresh token'); } const user = await models.User.findById(decoded.userId).select('-password'); if (!user || !user.isActive || user.isLocked) { throw new Error('User not allowed'); } const accessToken = jwt.sign( { userId: user._id, aud: 'client', iss: 'ideal-photography', type: 'access' }, process.env.JWT_SECRET, { expiresIn: process.env.JWT_EXPIRES_IN || '3d' } ); const refreshToken = jwt.sign( { userId: user._id, aud: 'client', iss: 'ideal-photography', type: 'refresh' }, process.env.JWT_REFRESH_SECRET, { expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d' } ); const response = { token: accessToken, refreshToken, user, expiresIn: process.env.JWT_EXPIRES_IN || '3d' }; try { await context?.audit?.('Mutation.refreshToken', {}, { status: 'success' }); } catch (_) { } return response; } catch (error) { try { await context?.audit?.('Mutation.refreshToken', {}, { status: 'failure', message: 'Invalid refresh token' }); } catch (_) { } throw new Error('Invalid refresh token'); } }, changePassword: async (_, { currentPassword, newPassword }, { user, ...context }) => { if (!user) throw new Error('Authentication required'); const dbUser = await models.User.findById(user._id); const isValid = await dbUser.comparePassword(currentPassword); if (!isValid) throw new Error('Current password is incorrect'); dbUser.password = newPassword; await dbUser.save(); try { await context?.audit?.('Mutation.changePassword', {}, { status: 'success' }); } catch (_) { } return true; }, forgotPassword: async (_, { email }, context) => { try { const user = await models.User.findOne({ email: email.toLowerCase() }); if (!user) { // Don't reveal if user exists or not try { await context?.audit?.('Mutation.forgotPassword', {}, { status: 'success' }); } catch (_) { } return true; } // Generate reset token const resetToken = jwt.sign( { userId: user._id, aud: 'client', iss: 'ideal-photography', type: 'password_reset' }, process.env.JWT_SECRET, { expiresIn: '1h' } ); user.passwordResetToken = resetToken; user.passwordResetExpires = new Date(Date.now() + 3600000); // 1 hour await user.save(); // Send email with reset link const baseUrl = process.env.CLIENT_URL || 'http://localhost:3000'; const resetUrl = `${baseUrl}/reset-password?token=${encodeURIComponent(resetToken)}&email=${encodeURIComponent(user.email)}`; const { html, text } = buildResetPasswordEmail({ name: user.name, url: resetUrl }); try { await sendMail({ to: user.email, subject: 'Reset your password', html, text }); } catch (mailErr) { console.error('Failed to send reset email:', mailErr); } try { await context?.audit?.('Mutation.forgotPassword', {}, { status: 'success' }); } catch (_) { } return true; } catch (error) { throw new Error('Failed to process password reset request'); } }, resetPassword: async (_, { token, newPassword }, context) => { try { const decoded = jwt.verify(token, process.env.JWT_SECRET); if (decoded.aud && decoded.aud !== 'client') { throw new Error('Invalid or expired reset token'); } const user = await models.User.findById(decoded.userId); if (!user || user.passwordResetToken !== token || user.passwordResetExpires < new Date()) { throw new Error('Invalid or expired reset token'); } user.password = newPassword; user.passwordResetToken = undefined; user.passwordResetExpires = undefined; await user.save(); try { await context?.audit?.('Mutation.resetPassword', {}, { status: 'success' }); } catch (_) { } return true; } catch (error) { throw new Error('Failed to reset password'); } }, verifyEmail: async (_, { token }, context) => { try { const decoded = jwt.verify(token, process.env.JWT_SECRET); if (decoded.aud && decoded.aud !== 'client') { throw new Error('Invalid or expired verification token'); } const user = await models.User.findById(decoded.userId); if (!user || user.emailVerificationToken !== token || user.emailVerificationExpires < new Date()) { throw new Error('Invalid or expired verification token'); } user.isEmailVerified = true; user.emailVerificationToken = undefined; user.emailVerificationExpires = undefined; await user.save(); try { await context?.audit?.('Mutation.verifyEmail', {}, { status: 'success' }); } catch (_) { } return true; } catch (error) { throw new Error('Failed to verify email'); } }, sendEmailVerification: async (_, __, { user, ...context }) => { if (!user) throw new Error('Authentication required'); try { const emailToken = jwt.sign( { userId: user._id }, process.env.JWT_SECRET, { expiresIn: '24h' } ); await models.User.findByIdAndUpdate(user._id, { emailVerificationToken: emailToken, emailVerificationExpires: new Date(Date.now() + 24 * 60 * 60 * 1000) }); const baseUrl = process.env.CLIENT_URL || 'http://localhost:3000'; const verifyUrl = `${baseUrl}/email-verification?token=${encodeURIComponent(emailToken)}&email=${encodeURIComponent(user.email)}`; const { html, text } = buildVerificationEmail({ name: user.name, url: verifyUrl }); try { await sendMail({ to: user.email, subject: 'Verify your email', html, text }); } catch (mailErr) { console.error('Failed to send verification email:', mailErr); } try { await context?.audit?.('Mutation.sendEmailVerification', {}, { status: 'success' }); } catch (_) { } return true; } catch (error) { throw new Error('Failed to send verification email'); } }, submitVerificationDocument: async (_, { input }, { user }) => { if (!user) { throw new Error('Authentication required'); } try { const updateData = {}; updateData[`verification.${input.type}.number`] = input.number; updateData[`verification.${input.type}.status`] = 'pending'; updateData[`verification.${input.type}.submittedAt`] = new Date(); // Only set document if provided if (input.document) { updateData[`verification.${input.type}.document`] = input.document; } await models.User.findByIdAndUpdate(user._id, updateData); // Notify admins of new verification submission try { const adminIds = await getActiveAdminIds(); if (adminIds.length > 0) { await sendAdminVerificationNotification(adminIds, user, input.type); } } catch (notificationError) { console.error('Failed to send admin verification notification:', notificationError); } return { success: true, message: 'ID verification submitted successfully' }; } catch (error) { throw new Error('Failed to submit ID verification'); } }, // Admin user management mutations updateUser: async (_, { id, input }, { user, ...context }) => { if (!user || user.constructor.modelName !== 'Admin') { throw new Error('Admin access required'); } try { const updatedUser = await models.User.findByIdAndUpdate(id, input, { new: true }); if (!updatedUser) { throw new Error('User not found'); } // Invalidate related caches CacheInvalidation.user(id); try { await context?.audit?.('Mutation.updateUser', { userId: id }, { status: 'success' }); } catch (_) { } return DataTransformer.transformDocument(updatedUser); } catch (error) { try { await context?.audit?.('Mutation.updateUser', { userId: id }, { status: 'failure', message: error.message }); } catch (_) { } throw new Error(error.message); } }, activateUser: async (_, { id }, { user, ...context }) => { if (!user || user.constructor.modelName !== 'Admin') { throw new Error('Admin access required'); } try { const updatedUser = await models.User.findByIdAndUpdate( id, { isActive: true }, { new: true } ); if (!updatedUser) { throw new Error('User not found'); } try { await context?.audit?.('Mutation.activateUser', { userId: id }, { status: 'success' }); } catch (_) { } return updatedUser; } catch (error) { try { await context?.audit?.('Mutation.activateUser', { userId: id }, { status: 'failure', message: error.message }); } catch (_) { } throw new Error(error.message); } }, deactivateUser: async (_, { id }, { user, ...context }) => { if (!user || user.constructor.modelName !== 'Admin') { throw new Error('Admin access required'); } try { const updatedUser = await models.User.findByIdAndUpdate( id, { isActive: false }, { new: true } ); if (!updatedUser) { throw new Error('User not found'); } try { await context?.audit?.('Mutation.deactivateUser', { userId: id }, { status: 'success' }); } catch (_) { } return updatedUser; } catch (error) { try { await context?.audit?.('Mutation.deactivateUser', { userId: id }, { status: 'failure', message: error.message }); } catch (_) { } throw new Error(error.message); } }, updateUserRole: async (_, { id, role, permissions }, { user, ...context }) => { if (!user || user.constructor.modelName !== 'Admin') { throw new Error('Admin access required'); } try { // Users can only have 'client' role - this is for updating user permissions within client role const updatedUser = await models.User.findByIdAndUpdate( id, { permissions }, // Only update permissions, role stays 'client' { new: true } ); if (!updatedUser) { throw new Error('User not found'); } try { await context?.audit?.('Mutation.updateUserRole', { userId: id, permissions }, { status: 'success' }); } catch (_) { } return updatedUser; } catch (error) { try { await context?.audit?.('Mutation.updateUserRole', { userId: id, permissions }, { status: 'failure', message: error.message }); } catch (_) { } throw new Error(error.message); } }, approveVerification: async (_, { userId, type }, { user, ...context }) => { if (!user || user.constructor.modelName !== 'Admin') { throw new Error('Admin access required'); } try { const updateData = {}; updateData[`verification.${type}.status`] = 'verified'; updateData[`verification.${type}.verifiedAt`] = new Date(); updateData[`verification.${type}.rejectionReason`] = undefined; const updatedUser = await models.User.findByIdAndUpdate(userId, updateData, { new: true }); if (!updatedUser) { throw new Error('User not found'); } // Send verification approval email and notification try { const preferences = await checkNotificationPreferences(userId); // Send verification approval email (background job) if (preferences.email) { jobQueue.add('send-id-verification-status', { userId: updatedUser._id.toString(), email: updatedUser.email, name: updatedUser.name, type, status: 'verified' }, { priority: 8 }); // High priority for verification status } // Send verification approval push notification if (preferences.push) { await sendVerificationNotification(userId, type, 'verified'); } } catch (notificationError) { console.error('Failed to send verification approval notifications:', notificationError); } try { await context?.audit?.('Mutation.approveVerification', { userId, type }, { status: 'success' }); } catch (_) { } return { success: true, message: `${type} verification approved successfully` }; } catch (error) { try { await context?.audit?.('Mutation.approveVerification', { userId, type }, { status: 'failure', message: error.message }); } catch (_) { } throw new Error(error.message); } }, rejectVerification: async (_, { userId, type, reason }, { user, ...context }) => { if (!user || user.constructor.modelName !== 'Admin') { throw new Error('Admin access required'); } try { const updateData = {}; updateData[`verification.${type}.status`] = 'rejected'; updateData[`verification.${type}.rejectionReason`] = reason; updateData[`verification.${type}.verifiedAt`] = undefined; const updatedUser = await models.User.findByIdAndUpdate(userId, updateData, { new: true }); if (!updatedUser) { throw new Error('User not found'); } // Send verification rejection email and notification try { const preferences = await checkNotificationPreferences(userId); // Send verification rejection email (background job) if (preferences.email) { jobQueue.add('send-id-verification-status', { userId: updatedUser._id.toString(), email: updatedUser.email, name: updatedUser.name, type, status: 'rejected', reason }, { priority: 8 }); // High priority for verification status } // Send verification rejection push notification if (preferences.push) { await sendVerificationNotification(userId, type, 'rejected', reason); } } catch (notificationError) { console.error('Failed to send verification rejection notifications:', notificationError); } try { await context?.audit?.('Mutation.rejectVerification', { userId, type, reason }, { status: 'success' }); } catch (_) { } return { success: true, message: `${type} verification rejected` }; } catch (error) { try { await context?.audit?.('Mutation.rejectVerification', { userId, type, reason }, { status: 'failure', message: error.message }); } catch (_) { } throw new Error(error.message); } }, unlockAccount: async (_, { id }, { user, ...context }) => { if (!user || user.constructor.modelName !== 'Admin') { throw new Error('Admin access required'); } try { const updatedUser = await models.User.findByIdAndUpdate( id, { $unset: { lockUntil: 1, loginAttempts: 1 } }, { new: true } ); if (!updatedUser) { throw new Error('User not found'); } try { await context?.audit?.('Mutation.unlockAccount', { userId: id }, { status: 'success' }); } catch (_) { } return updatedUser; } catch (error) { try { await context?.audit?.('Mutation.unlockAccount', { userId: id }, { status: 'failure', message: error.message }); } catch (_) { } throw new Error(error.message); } }, resetLoginAttempts: async (_, { id }, { user, ...context }) => { if (!user || user.constructor.modelName !== 'Admin') { throw new Error('Admin access required'); } try { const targetUser = await models.User.findById(id); if (!targetUser) { throw new Error('User not found'); } await targetUser.resetLoginAttempts(); const updatedUser = await models.User.findById(id); try { await context?.audit?.('Mutation.resetLoginAttempts', { userId: id }, { status: 'success' }); } catch (_) { } return updatedUser; } catch (error) { try { await context?.audit?.('Mutation.resetLoginAttempts', { userId: id }, { status: 'failure', message: error.message }); } catch (_) { } throw new Error(error.message); } }, bulkUpdateUsers: async (_, { ids, input }, { user, ...context }) => { if (!user || user.constructor.modelName !== 'Admin') { throw new Error('Admin access required'); } try { await models.User.updateMany({ _id: { $in: ids } }, input); const updatedUsers = await models.User.find({ _id: { $in: ids } }); try { await context?.audit?.('Mutation.bulkUpdateUsers', { userIds: ids }, { status: 'success' }); } catch (_) { } return updatedUsers; } catch (error) { try { await context?.audit?.('Mutation.bulkUpdateUsers', { userIds: ids }, { status: 'failure', message: error.message }); } catch (_) { } throw new Error(error.message); } }, deleteUser: async (_, { id }, { user, ...context }) => { if (!user || user.constructor.modelName !== 'Admin') { throw new Error('Admin access required'); } try { const deletedUser = await models.User.findByIdAndDelete(id); if (!deletedUser) { throw new Error('User not found'); } try { await context?.audit?.('Mutation.deleteUser', { userId: id }, { status: 'success' }); } catch (_) { } return true; } catch (error) { try { await context?.audit?.('Mutation.deleteUser', { userId: id }, { status: 'failure', message: error.message }); } catch (_) { } throw new Error(error.message); } }, bulkDeleteUsers: async (_, { ids }, { user, ...context }) => { if (!user || user.constructor.modelName !== 'Admin') { throw new Error('Admin access required'); } try { await models.User.deleteMany({ _id: { $in: ids } }); try { await context?.audit?.('Mutation.bulkDeleteUsers', { userIds: ids }, { status: 'success' }); } catch (_) { } return true; } catch (error) { try { await context?.audit?.('Mutation.bulkDeleteUsers', { userIds: ids }, { status: 'failure', message: error.message }); } catch (_) { } throw new Error(error.message); } } } }; export default userResolvers;