| |
| const bcrypt = require('bcryptjs'); |
| const jwt = require('jsonwebtoken'); |
| const { GraphQLError } = require('graphql'); |
| const mongoose = require('mongoose'); |
|
|
| |
| const { |
| User, |
| Comment, |
| ProfileAnalytics, |
| Coupon, |
| Rating, |
| Report, |
| Post, |
| Story, |
| Job, |
| JobApplication, |
| JobSave, |
| JobCategory, |
| Product, |
| Order, |
| Page, |
| Cart, |
| StoreFollow, |
| Follow, |
| Notification, |
| Conversation, |
| Project, |
| MessageReport, |
| Message, |
| SiteSettings, |
| AIConversation, |
| SubscriptionEarnings, |
| UserSubscription, |
| PlatformPaymentMethod, |
| SubscriptionPlan, |
| StoreSettings, |
| StoreTheme |
| } = require('../server'); |
|
|
| |
| const ShortcodeService = require('../services/ShortcodeService'); |
| const { sendEmailWithBrevo, sendStoreEmailWithBrevo } = require('../server'); |
| const TemplateStore = require('../models/TemplateStore'); |
|
|
| |
| |
| |
|
|
| function detectLanguage(text) { |
| const arabicPattern = /[\u0600-\u06FF]/; |
| if (arabicPattern.test(text)) return 'ar'; |
| const englishPattern = /[a-zA-Z]/; |
| if (englishPattern.test(text)) return 'en'; |
| return 'ar'; |
| } |
|
|
| async function saveAIConversation(userId, question, answer, provider, language, responseTime, visitorIp) { |
| try { |
| await AIConversation.create({ |
| userId, |
| visitorIp: visitorIp || '', |
| question: question.substring(0, 500), |
| answer: answer.substring(0, 1000), |
| provider, |
| language, |
| responseTime: responseTime || 0 |
| }); |
| } catch (error) { |
| console.error('Error saving AI conversation:', error); |
| } |
| } |
|
|
| async function updateUserPermissions(userId, plan) { |
| const user = await User.findById(userId); |
| if (!user) return; |
| |
| if (!plan) { |
| user.profile.storeEnabled = false; |
| } else { |
| user.profile.storeEnabled = plan.features?.storeEnabled || false; |
| } |
| await user.save(); |
| } |
|
|
| |
| |
| |
|
|
| const resolvers = { |
| |
| |
| |
| Date: { |
| serialize: (value) => value ? new Date(value).toISOString() : null, |
| parseValue: (value) => value ? new Date(value) : null, |
| parseLiteral: (ast) => ast.value ? new Date(ast.value) : null |
| }, |
| |
| JSON: { |
| serialize: (value) => value, |
| parseValue: (value) => value, |
| parseLiteral: (ast) => ast.value |
| }, |
|
|
| Upload: { |
| serialize: () => null, |
| parseValue: () => null, |
| parseLiteral: () => null |
| }, |
|
|
| |
| |
| |
| User: { |
| profile: (parent) => { |
| const p = parent.profile || {}; |
| |
| |
| return { |
| ...p, |
| education: Array.isArray(p.education) ? p.education : Object.values(p.education || {}), |
| experience: Array.isArray(p.experience) ? p.experience : Object.values(p.experience || {}), |
| certificates: Array.isArray(p.certificates) ? p.certificates : Object.values(p.certificates || {}), |
| skills: Array.isArray(p.skills) ? p.skills : Object.values(p.skills || {}), |
| projects: Array.isArray(p.projects) ? p.projects : Object.values(p.projects || {}), |
| interests: Array.isArray(p.interests) ? p.interests : Object.values(p.interests || {}), |
| customSections: Array.isArray(p.customSections) ? p.customSections : Object.values(p.customSections || {}), |
| sectionOrder: Array.isArray(p.sectionOrder) ? p.sectionOrder : Object.values(p.sectionOrder || {}), |
| }; |
| }, |
| |
| followersCount: async (parent, args, context) => { |
| if (context.loaders?.followersCountLoader) { |
| return await context.loaders.followersCountLoader.load(parent._id.toString()); |
| } |
| try { |
| const count = await Follow.countDocuments({ followingId: parent._id, status: 'accepted' }); |
| return count || 0; |
| } catch (error) { |
| return 0; |
| } |
| }, |
| |
| followingCount: async (parent, args, context) => { |
| if (context.loaders?.followingCountLoader) { |
| return await context.loaders.followingCountLoader.load(parent._id.toString()); |
| } |
| try { |
| const count = await Follow.countDocuments({ followerId: parent._id, status: 'accepted' }); |
| return count || 0; |
| } catch (error) { |
| return 0; |
| } |
| }, |
| |
| postsCount: async (parent, args, context) => { |
| if (context.loaders?.postsCountLoader) { |
| return await context.loaders.postsCountLoader.load(parent._id.toString()); |
| } |
| try { |
| const count = await Post.countDocuments({ userId: parent._id }); |
| return count || 0; |
| } catch (error) { |
| return 0; |
| } |
| }, |
| |
| projectsCount: async (parent, args, context) => { |
| try { |
| const count = await Project.countDocuments({ userId: parent._id }); |
| return count || 0; |
| } catch (error) { |
| return 0; |
| } |
| }, |
| |
| jobsCount: async (parent, args, context) => { |
| try { |
| const count = await Job.countDocuments({ employerId: parent._id }); |
| return count || 0; |
| } catch (error) { |
| return 0; |
| } |
| }, |
| |
| totalLikes: async (parent, args, context) => { |
| try { |
| const result = await Post.aggregate([ |
| { $match: { userId: parent._id } }, |
| { $project: { likesCount: { $size: '$likes' } } }, |
| { $group: { _id: null, total: { $sum: '$likesCount' } } } |
| ]); |
| return result[0]?.total || 0; |
| } catch (error) { |
| return 0; |
| } |
| }, |
| |
| store: async (parent, args, context) => { |
| try { |
| const storeSettings = await StoreSettings.findOne({ userId: parent._id }); |
| if (!storeSettings) return null; |
| |
| const productsCount = await Product.countDocuments({ userId: parent._id, isActive: true }); |
| const followersCount = await StoreFollow.countDocuments({ storeId: parent._id }); |
| |
| return { |
| _id: parent._id, |
| userId: parent._id, |
| storeName: storeSettings.storeName || parent.profile?.nickname || parent.username, |
| storeLogo: storeSettings.storeLogo, |
| storeBanner: storeSettings.storeBanner, |
| storeDescription: storeSettings.storeDescription, |
| contactEmail: storeSettings.contactEmail || parent.email, |
| enabled: storeSettings.enabled || false, |
| currency: storeSettings.currency || 'USD', |
| currencySymbol: storeSettings.currencySymbol || '$', |
| productsCount: productsCount, |
| followersCount: followersCount, |
| averageRating: storeSettings.averageRating || 0, |
| totalSales: 0, |
| totalOrders: 0, |
| createdAt: storeSettings.createdAt, |
| updatedAt: storeSettings.updatedAt |
| }; |
| } catch (error) { |
| return null; |
| } |
| }, |
| |
| subscription: async (parent, args, context) => { |
| try { |
| const subscription = await UserSubscription.findOne({ |
| userId: parent._id, |
| status: 'active' |
| }).populate('planId'); |
| return subscription || null; |
| } catch (error) { |
| return null; |
| } |
| }, |
| |
| permissions: async (parent, args, context) => { |
| try { |
| const storeSettings = await StoreSettings.findOne({ userId: parent._id }); |
| const subscription = await UserSubscription.findOne({ |
| userId: parent._id, |
| status: 'active' |
| }).populate('planId'); |
| |
| return { |
| canCreateStore: !!(storeSettings?.enabled), |
| canCreateProducts: !!(subscription?.planId?.features?.storeEnabled) |
| }; |
| } catch (error) { |
| return { |
| canCreateStore: false, |
| canCreateProducts: false |
| }; |
| } |
| }, |
|
|
| hasStory: async (parent) => { |
| try { |
| const now = new Date(); |
| const storyCount = await Story.countDocuments({ |
| userId: parent._id, |
| expiresAt: { $gt: now } |
| }); |
| return storyCount > 0; |
| } catch (error) { |
| console.error('hasStory error:', error); |
| return false; |
| } |
| } |
|
|
|
|
| }, |
| |
| |
| |
| Comment: { |
| projectId: async (parent) => { |
| return await Project.findById(parent.projectId); |
| }, |
| projectOwnerId: async (parent) => { |
| return await User.findById(parent.projectOwnerId); |
| }, |
| userId: async (parent) => { |
| return await User.findById(parent.userId); |
| }, |
| likes: async (parent) => { |
| if (!parent || !parent.likes || !Array.isArray(parent.likes) || parent.likes.length === 0) { |
| return []; |
| } |
| return await User.find({ _id: { $in: parent.likes } }); |
| }, |
| parentCommentId: async (parent) => { |
| if (!parent.parentCommentId) return null; |
| return await Comment.findById(parent.parentCommentId); |
| }, |
| replies: async (parent) => { |
| if (!parent || !parent.replies || !Array.isArray(parent.replies) || parent.replies.length === 0) { |
| return []; |
| } |
| return await Comment.find({ _id: { $in: parent.replies } }) |
| .populate('userId', 'username profile.nickname profile.avatar') |
| .sort({ timestamp: 1 }); |
| }, |
| reports: (parent) => parent.reports || [], |
| repliesCount: (parent) => { |
| if (!parent || !parent.replies || !Array.isArray(parent.replies)) return 0; |
| return parent.replies.length; |
| }, |
| likesCount: (parent) => { |
| if (!parent || !parent.likes || !Array.isArray(parent.likes)) return 0; |
| return parent.likes.length; |
| }, |
| isLiked: (parent, args, context) => { |
| if (!context.user) return false; |
| if (!parent || !parent.likes || !Array.isArray(parent.likes)) return false; |
| return parent.likes.some(id => id.toString() === context.user.userId); |
| } |
| }, |
| CommentReport: { |
| userId: async (parent) => { |
| return await User.findById(parent.userId); |
| }, |
| resolvedBy: async (parent) => { |
| if (!parent.resolvedBy) return null; |
| return await User.findById(parent.resolvedBy); |
| } |
| }, |
|
|
| |
| |
| |
| Profile: { |
| socialLinks: (parent) => parent.socialLinks || {}, |
| |
| storeEnabled: (parent) => { |
| return parent.storeEnabled || false; |
| }, |
| averageRating: (parent) => { |
| return parent.averageRating || 0; |
| }, |
| totalRatings: (parent) => { |
| return parent.totalRatings || 0; |
| }, |
|
|
| |
| education: (parent) => { |
| if (!parent.education) return []; |
| if (Array.isArray(parent.education)) return parent.education; |
| if (typeof parent.education === 'object') return Object.values(parent.education); |
| return []; |
| }, |
| |
| experience: (parent) => { |
| if (!parent.experience) return []; |
| if (Array.isArray(parent.experience)) return parent.experience; |
| if (typeof parent.experience === 'object') return Object.values(parent.experience); |
| return []; |
| }, |
| |
| certificates: (parent) => { |
| if (!parent.certificates) return []; |
| if (Array.isArray(parent.certificates)) return parent.certificates; |
| if (typeof parent.certificates === 'object') return Object.values(parent.certificates); |
| return []; |
| }, |
| |
| skills: (parent) => { |
| if (!parent.skills) return []; |
| if (Array.isArray(parent.skills)) return parent.skills; |
| if (typeof parent.skills === 'object') return Object.values(parent.skills); |
| return []; |
| }, |
| |
| projects: (parent) => { |
| if (!parent.projects) return []; |
| if (Array.isArray(parent.projects)) return parent.projects; |
| if (typeof parent.projects === 'object') return Object.values(parent.projects); |
| return []; |
| }, |
| |
| interests: (parent) => { |
| if (!parent.interests) return []; |
| if (Array.isArray(parent.interests)) return parent.interests; |
| if (typeof parent.interests === 'object') return Object.values(parent.interests); |
| return []; |
| }, |
| |
| customSections: (parent) => { |
| if (!parent.customSections) return []; |
| if (Array.isArray(parent.customSections)) return parent.customSections; |
| if (typeof parent.customSections === 'object') return Object.values(parent.customSections); |
| return []; |
| }, |
| |
| sectionOrder: (parent) => { |
| if (!parent.sectionOrder) return []; |
| if (Array.isArray(parent.sectionOrder)) return parent.sectionOrder; |
| if (typeof parent.sectionOrder === 'object') return Object.values(parent.sectionOrder); |
| return []; |
| }, |
|
|
| hasStory: async (parent, args, context) => { |
| |
| const userId = parent._id || parent.userId; |
| if (!userId) return false; |
| try { |
| const count = await Story.countDocuments({ |
| userId: userId, |
| expiresAt: { $gt: new Date() } |
| }); |
| return count > 0; |
| } catch (error) { |
| return false; |
| } |
| }, |
| |
| |
| sectionNames: (parent) => parent.sectionNames || {}, |
| sectionVisibility: (parent) => parent.sectionVisibility || {}, |
| sectionStyleSettings: (parent) => parent.sectionStyleSettings || {}, |
| designSettings: (parent) => parent.designSettings || {}, |
| aiBot: (parent) => parent.aiBot || { enabled: false, provider: 'mgzon' }, |
| audio: (parent) => parent.audio || {}, |
| theme: (parent) => parent.theme || {}, |
| layout: (parent) => parent.layout || {}, |
| header: (parent) => parent.header || {}, |
| footer: (parent) => parent.footer || {}, |
| seo: (parent) => parent.seo || {}, |
| schema: (parent) => parent.schema || {}, |
| }, |
|
|
| Project: { |
| userId: async (parent) => { |
| return await User.findById(parent.userId); |
| } |
| }, |
|
|
| |
| |
| |
| Post: { |
| userId: async (parent) => { |
| return await User.findById(parent.userId); |
| }, |
| likesCount: (parent) => { |
| |
| if (!parent || !parent.likes || !Array.isArray(parent.likes)) { |
| return 0; |
| } |
| return parent.likes.length; |
| }, |
| commentsCount: (parent) => { |
| if (!parent || !parent.comments || !Array.isArray(parent.comments)) { |
| return 0; |
| } |
| return parent.comments.length; |
| }, |
| sharesCount: (parent) => { |
| if (!parent || !parent.shares || !Array.isArray(parent.shares)) { |
| return 0; |
| } |
| return parent.shares.length; |
| }, |
| savesCount: (parent) => { |
| if (!parent || !parent.saves || !Array.isArray(parent.saves)) { |
| return 0; |
| } |
| return parent.saves.length; |
| }, |
| isLiked: (parent, args, context) => { |
| if (!context.user) return false; |
| if (!parent || !parent.likes || !Array.isArray(parent.likes)) return false; |
| return parent.likes.some(l => l.userId?.toString() === context.user.userId); |
| }, |
| isSaved: (parent, args, context) => { |
| if (!context.user) return false; |
| if (!parent || !parent.saves || !Array.isArray(parent.saves)) return false; |
| return parent.saves.some(s => s.userId?.toString() === context.user.userId); |
| } |
| }, |
|
|
| |
| |
| |
| Story: { |
| userId: async (parent) => { |
| return await User.findById(parent.userId); |
| }, |
| viewsCount: (parent) => { |
| if (!parent || !parent.views || !Array.isArray(parent.views)) { |
| return 0; |
| } |
| return parent.views.length; |
| }, |
| reactionsCount: (parent) => { |
| if (!parent || !parent.reactions || !Array.isArray(parent.reactions)) { |
| return 0; |
| } |
| return parent.reactions.length; |
| }, |
| hasViewed: (parent, args, context) => { |
| if (!context.user) return false; |
| if (!parent || !parent.views || !Array.isArray(parent.views)) return false; |
| return parent.views.some(v => v.userId?.toString() === context.user.userId); |
| } |
| }, |
|
|
| StoryGroup: { |
| user: (parent) => parent.user, |
| stories: (parent) => parent.stories |
| }, |
|
|
| |
| |
| |
| Job: { |
| employerId: async (parent) => { |
| return await User.findById(parent.employerId); |
| }, |
| hasApplied: async (parent, args, context) => { |
| if (!context.user) return false; |
| const application = await JobApplication.findOne({ |
| jobId: parent._id, |
| applicantId: context.user.userId |
| }); |
| return !!application; |
| }, |
| applicationStatus: async (parent, args, context) => { |
| if (!context.user) return null; |
| const application = await JobApplication.findOne({ |
| jobId: parent._id, |
| applicantId: context.user.userId |
| }); |
| return application?.status || null; |
| }, |
| isSaved: async (parent, args, context) => { |
| if (!context.user) return false; |
| const saved = await JobSave.findOne({ |
| jobId: parent._id, |
| userId: context.user.userId |
| }); |
| return !!saved; |
| } |
| }, |
|
|
| JobApplication: { |
| jobId: async (parent) => { |
| return await Job.findById(parent.jobId); |
| }, |
| applicantId: async (parent) => { |
| return await User.findById(parent.applicantId); |
| } |
| }, |
|
|
| JobCategory: { |
| displayName: (parent, args, context) => { |
| |
| const lang = args?.lang || 'en'; |
| return lang === 'ar' ? (parent.nameAr || parent.name) : parent.name; |
| }, |
| count: async (parent) => { |
| return await Job.countDocuments({ |
| category: parent.name, |
| status: 'active', |
| deadline: { $gt: new Date() } |
| }); |
| } |
| }, |
|
|
| |
| |
| |
| Store: { |
| userId: async (parent) => { |
| return await User.findById(parent.userId || parent._id); |
| }, |
| productsCount: async (parent) => { |
| return await Product.countDocuments({ userId: parent.userId || parent._id, isActive: true }); |
| }, |
| followersCount: async (parent) => { |
| return await StoreFollow.countDocuments({ storeId: parent.userId || parent._id }); |
| }, |
| totalSales: async (parent) => { |
| const orders = await Order.find({ |
| sellerId: parent.userId || parent._id, |
| status: 'completed' |
| }); |
| return orders.reduce((sum, o) => sum + o.totalAmount, 0); |
| }, |
| totalOrders: async (parent) => { |
| return await Order.countDocuments({ sellerId: parent.userId || parent._id }); |
| }, |
| averageRating: async (parent) => { |
| const settings = await StoreSettings.findOne({ userId: parent.userId || parent._id }); |
| return settings?.averageRating || 0; |
| } |
| }, |
|
|
| StoreProduct: { |
| userId: async (parent) => { |
| return await User.findById(parent.userId); |
| }, |
| averageRating: (parent) => { |
| if (!parent.reviews || parent.reviews.length === 0) return 0; |
| const sum = parent.reviews.reduce((acc, r) => acc + r.rating, 0); |
| return sum / parent.reviews.length; |
| }, |
| reviews: (parent) => parent.reviews || [] |
| }, |
|
|
| ProductReview: { |
| userId: async (parent) => { |
| return await User.findById(parent.userId); |
| } |
| }, |
|
|
| Order: { |
| buyerId: async (parent) => { |
| return await User.findById(parent.buyerId); |
| }, |
| sellerId: async (parent) => { |
| return await User.findById(parent.sellerId); |
| } |
| }, |
|
|
| Cart: { |
| userId: async (parent) => { |
| return await User.findById(parent.userId); |
| }, |
| stores: (parent) => { |
| if (!parent.stores) return []; |
| const stores = []; |
| for (let [storeId, storeData] of parent.stores) { |
| stores.push({ |
| ...storeData, |
| storeId: storeId |
| }); |
| } |
| return stores; |
| } |
| }, |
|
|
| CartStore: { |
| storeId: (parent) => parent.storeId, |
| items: (parent) => parent.items || [] |
| }, |
|
|
| |
| |
| |
| Page: { |
| storeId: async (parent) => { |
| return await User.findById(parent.storeId); |
| }, |
| lockedBy: async (parent) => { |
| if (!parent.lockedBy) return null; |
| return await User.findById(parent.lockedBy); |
| }, |
| createdBy: async (parent) => { |
| if (!parent.createdBy) return null; |
| return await User.findById(parent.createdBy); |
| }, |
| updatedBy: async (parent) => { |
| if (!parent.updatedBy) return null; |
| return await User.findById(parent.updatedBy); |
| }, |
| url: (parent) => { |
| return `/shop/${parent.storeId}/page/${parent.slug}`; |
| }, |
| isLocked: (parent) => { |
| if (!parent.lockedAt) return false; |
| return (Date.now() - new Date(parent.lockedAt).getTime()) < 30 * 60 * 1000; |
| } |
| }, |
|
|
| |
| |
| |
| StoreTheme: { |
| userId: async (parent) => { |
| return await User.findById(parent.userId); |
| }, |
| isCustomTheme: (parent) => !!parent.uploadedTheme?.fileUrl, |
| themeUrl: (parent) => { |
| return `/api/store/theme/${parent.userId}`; |
| } |
| }, |
|
|
| |
| |
| |
| UserSubscription: { |
| userId: async (parent) => { |
| return await User.findById(parent.userId); |
| }, |
| planId: async (parent) => { |
| return await SubscriptionPlan.findById(parent.planId); |
| }, |
| approvedBy: async (parent) => { |
| if (!parent.approvedBy) return null; |
| return await User.findById(parent.approvedBy); |
| } |
| }, |
|
|
| |
| |
| |
| Notification: { |
| userId: async (parent) => { |
| return await User.findById(parent.userId); |
| }, |
| actorId: async (parent) => { |
| if (!parent.actorId) return null; |
| return await User.findById(parent.actorId); |
| } |
| }, |
|
|
| |
| |
| |
| Report: { |
| reporterId: async (parent) => { |
| return await User.findById(parent.reporterId); |
| }, |
| resolvedBy: async (parent) => { |
| if (!parent.resolvedBy) return null; |
| return await User.findById(parent.resolvedBy); |
| } |
| }, |
|
|
| MessageReport: { |
| messageId: async (parent) => { |
| return await Message.findById(parent.messageId); |
| }, |
| reporterId: async (parent) => { |
| return await User.findById(parent.reporterId); |
| }, |
| reportedUserId: async (parent) => { |
| return await User.findById(parent.reportedUserId); |
| }, |
| conversationId: async (parent) => { |
| if (!parent.conversationId) return null; |
| return await Conversation.findById(parent.conversationId); |
| }, |
| resolvedBy: async (parent) => { |
| if (!parent.resolvedBy) return null; |
| return await User.findById(parent.resolvedBy); |
| } |
| }, |
|
|
| Message: { |
| conversationId: async (parent) => { |
| return await Conversation.findById(parent.conversationId); |
| }, |
| senderId: async (parent) => { |
| return await User.findById(parent.senderId); |
| }, |
| readBy: async (parent) => { |
| const users = []; |
| for (const item of parent.readBy || []) { |
| const user = await User.findById(item.userId); |
| if (user) users.push({ userId: user, readAt: item.readAt }); |
| } |
| return users; |
| }, |
| replyTo: async (parent) => { |
| if (!parent.replyTo) return null; |
| return await Message.findById(parent.replyTo); |
| }, |
| originalMessageId: async (parent) => { |
| if (!parent.originalMessageId) return null; |
| return await Message.findById(parent.originalMessageId); |
| }, |
| deletedFor: async (parent) => { |
| return await User.find({ _id: { $in: parent.deletedFor || [] } }); |
| } |
| }, |
|
|
| Conversation: { |
| participants: async (parent) => { |
| return await User.find({ _id: { $in: parent.participants } }); |
| }, |
| lastMessage: (parent) => parent.lastMessage || null, |
| unreadCount: (parent, args, context) => { |
| if (!context.user) return 0; |
| return parent.unreadCount?.get(context.user.userId) || 0; |
| } |
| }, |
|
|
| MessageRead: { |
| userId: async (parent) => { |
| return await User.findById(parent.userId); |
| } |
| }, |
|
|
| |
| |
| |
| Follow: { |
| followerId: async (parent) => { |
| return await User.findById(parent.followerId); |
| }, |
| followingId: async (parent) => { |
| return await User.findById(parent.followingId); |
| } |
| }, |
|
|
| FollowRequest: { |
| requester: async (parent) => { |
| return await User.findById(parent.followerId); |
| }, |
| requestedAt: (parent) => parent.createdAt |
| }, |
|
|
| |
| |
| |
| Rating: { |
| targetUserId: async (parent) => { |
| return await User.findById(parent.targetUserId); |
| }, |
| raterUserId: async (parent) => { |
| return await User.findById(parent.raterUserId); |
| } |
| }, |
|
|
| |
| |
| |
| AIConversation: { |
| userId: async (parent) => { |
| return await User.findById(parent.userId); |
| } |
| }, |
|
|
| |
| |
| |
| Template: { |
| templateUrl: (parent) => { |
| if (parent.themeData?.templateUrl) return parent.themeData.templateUrl; |
| if (parent.themeData?.uploadedFiles?.htmlFiles?.length) { |
| const indexFile = parent.themeData.uploadedFiles.htmlFiles.find(f => |
| f.name === 'index.html' || f.name.endsWith('/index.html') |
| ); |
| return indexFile?.url || null; |
| } |
| return null; |
| }, |
| installedBy: async (parent) => { |
| const users = []; |
| for (const install of parent.installedBy || []) { |
| const user = await User.findById(install.userId); |
| if (user) users.push({ userId: user, installedAt: install.installedAt }); |
| } |
| return users; |
| } |
| }, |
|
|
| TemplateInstall: { |
| userId: async (parent) => { |
| return await User.findById(parent.userId); |
| } |
| }, |
|
|
| |
| |
| |
| SiteSettings: { |
| navbarLinks: (parent) => parent.navbarLinks || [] |
| } |
| }; |
|
|
| |
| |
| |
|
|
| const Query = { |
| |
| me: async (parent, args, context) => { |
| if (!context.user) { |
| return null; |
| } |
| |
| const user = await User.findById(context.user.userId).lean(); |
| if (!user) return null; |
| |
| |
| if (user.profile) { |
| const p = user.profile; |
| user.profile.education = Array.isArray(p.education) ? p.education : Object.values(p.education || {}); |
| user.profile.experience = Array.isArray(p.experience) ? p.experience : Object.values(p.experience || {}); |
| user.profile.certificates = Array.isArray(p.certificates) ? p.certificates : Object.values(p.certificates || {}); |
| user.profile.skills = Array.isArray(p.skills) ? p.skills : Object.values(p.skills || {}); |
| user.profile.projects = Array.isArray(p.projects) ? p.projects : Object.values(p.projects || {}); |
| user.profile.interests = Array.isArray(p.interests) ? p.interests : Object.values(p.interests || {}); |
| user.profile.customSections = Array.isArray(p.customSections) ? p.customSections : Object.values(p.customSections || {}); |
| user.profile.sectionOrder = Array.isArray(p.sectionOrder) ? p.sectionOrder : Object.values(p.sectionOrder || {}); |
| |
| user.profile.sectionNames = p.sectionNames || {}; |
| user.profile.sectionVisibility = p.sectionVisibility || {}; |
| user.profile.sectionStyleSettings = p.sectionStyleSettings || {}; |
| user.profile.designSettings = p.designSettings || {}; |
| user.profile.aiBot = p.aiBot || { enabled: false, provider: 'mgzon' }; |
| user.profile.audio = p.audio || {}; |
| user.profile.theme = p.theme || {}; |
| user.profile.layout = p.layout || {}; |
| user.profile.header = p.header || {}; |
| user.profile.footer = p.footer || {}; |
| user.profile.seo = p.seo || {}; |
| user.profile.schema = p.schema || {}; |
| user.profile.socialLinks = p.socialLinks || {}; |
| } |
| |
| |
| const [followersCount, followingCount, postsCount, projectsCount, jobsCount, storeSettings, subscription, totalLikesResult] = await Promise.all([ |
| Follow.countDocuments({ followingId: user._id, status: 'accepted' }), |
| Follow.countDocuments({ followerId: user._id, status: 'accepted' }), |
| Post.countDocuments({ userId: user._id }), |
| Project.countDocuments({ userId: user._id }), |
| Job.countDocuments({ employerId: user._id }), |
| StoreSettings.findOne({ userId: user._id }), |
| UserSubscription.findOne({ userId: user._id, status: 'active' }).populate('planId'), |
| Post.aggregate([ |
| { $match: { userId: user._id } }, |
| { $project: { likesCount: { $size: '$likes' } } }, |
| { $group: { _id: null, total: { $sum: '$likesCount' } } } |
| ]) |
| ]); |
| |
| user.followersCount = followersCount || 0; |
| user.followingCount = followingCount || 0; |
| user.postsCount = postsCount || 0; |
| user.projectsCount = projectsCount || 0; |
| user.jobsCount = jobsCount || 0; |
| user.totalLikes = totalLikesResult[0]?.total || 0; |
| |
| |
| if (storeSettings) { |
| const productsCount = await Product.countDocuments({ userId: user._id, isActive: true }); |
| const storeFollowersCount = await StoreFollow.countDocuments({ storeId: user._id }); |
| |
| user.store = { |
| _id: user._id, |
| userId: user._id, |
| storeName: storeSettings.storeName || user.profile?.nickname || user.username, |
| storeLogo: storeSettings.storeLogo, |
| storeBanner: storeSettings.storeBanner, |
| storeDescription: storeSettings.storeDescription, |
| contactEmail: storeSettings.contactEmail || user.email, |
| enabled: storeSettings.enabled || false, |
| currency: storeSettings.currency || 'USD', |
| currencySymbol: storeSettings.currencySymbol || '$', |
| productsCount: productsCount, |
| followersCount: storeFollowersCount, |
| averageRating: storeSettings.averageRating || 0, |
| totalSales: 0, |
| totalOrders: 0, |
| createdAt: storeSettings.createdAt, |
| updatedAt: storeSettings.updatedAt |
| }; |
| } else { |
| user.store = null; |
| } |
| |
| user.subscription = subscription || null; |
| |
| user.permissions = { |
| canCreateStore: !!(storeSettings?.enabled), |
| canCreateProducts: !!(subscription?.planId?.features?.storeEnabled) |
| }; |
| if (user.profile) { |
| user.profile.storeEnabled = storeSettings?.enabled || false; |
| user.profile.averageRating = storeSettings?.averageRating || 0; |
| user.profile.totalRatings = storeSettings?.totalRatings || 0; |
| } |
|
|
|
|
| |
| const now = new Date(); |
| const storyCount = await Story.countDocuments({ |
| userId: user._id, |
| expiresAt: { $gt: now } |
| }); |
| user.hasStory = storyCount > 0; |
| |
| return user; |
| }, |
| user: async (parent, { id }) => { |
| const user = await User.findById(id).lean(); |
| if (!user) return null; |
| |
| |
| if (user.profile) { |
| const p = user.profile; |
| user.profile.education = Array.isArray(p.education) ? p.education : Object.values(p.education || {}); |
| user.profile.experience = Array.isArray(p.experience) ? p.experience : Object.values(p.experience || {}); |
| user.profile.certificates = Array.isArray(p.certificates) ? p.certificates : Object.values(p.certificates || {}); |
| user.profile.skills = Array.isArray(p.skills) ? p.skills : Object.values(p.skills || {}); |
| user.profile.projects = Array.isArray(p.projects) ? p.projects : Object.values(p.projects || {}); |
| user.profile.interests = Array.isArray(p.interests) ? p.interests : Object.values(p.interests || {}); |
| user.profile.customSections = Array.isArray(p.customSections) ? p.customSections : Object.values(p.customSections || {}); |
| user.profile.sectionOrder = Array.isArray(p.sectionOrder) ? p.sectionOrder : Object.values(p.sectionOrder || {}); |
| |
| user.profile.sectionNames = p.sectionNames || {}; |
| user.profile.sectionVisibility = p.sectionVisibility || {}; |
| user.profile.sectionStyleSettings = p.sectionStyleSettings || {}; |
| user.profile.designSettings = p.designSettings || {}; |
| user.profile.aiBot = p.aiBot || { enabled: false, provider: 'mgzon' }; |
| user.profile.audio = p.audio || {}; |
| user.profile.theme = p.theme || {}; |
| user.profile.layout = p.layout || {}; |
| user.profile.header = p.header || {}; |
| user.profile.footer = p.footer || {}; |
| user.profile.seo = p.seo || {}; |
| user.profile.schema = p.schema || {}; |
| user.profile.socialLinks = p.socialLinks || {}; |
| } |
| |
| const [followersCount, followingCount, postsCount] = await Promise.all([ |
| Follow.countDocuments({ followingId: user._id, status: 'accepted' }), |
| Follow.countDocuments({ followerId: user._id, status: 'accepted' }), |
| Post.countDocuments({ userId: user._id }) |
| ]); |
| |
| user.followersCount = followersCount || 0; |
| user.followingCount = followingCount || 0; |
| user.postsCount = postsCount || 0; |
|
|
| |
| const now = new Date(); |
| const storyCount = await Story.countDocuments({ |
| userId: user._id, |
| expiresAt: { $gt: now } |
| }); |
| user.hasStory = storyCount > 0; |
| |
| return user; |
| }, |
|
|
| userByNickname: async (parent, { nickname }) => { |
| return await User.findOne({ |
| $or: [ |
| { 'profile.nickname': { $regex: `^${nickname}$`, $options: 'i' } }, |
| { username: { $regex: `^${nickname}$`, $options: 'i' } } |
| ] |
| }); |
| }, |
|
|
| profile: async (parent, { nickname }, context) => { |
| const user = await User.findOne({ |
| $or: [ |
| { 'profile.nickname': { $regex: `^${nickname}$`, $options: 'i' } }, |
| { username: { $regex: `^${nickname}$`, $options: 'i' } } |
| ] |
| }).lean(); |
| |
| if (!user) throw new GraphQLError('User not found'); |
| |
| const isOwner = context.user && context.user.userId === user._id.toString(); |
| if (!user.profile?.isPublic && !isOwner) { |
| throw new GraphQLError('Profile is private'); |
| } |
| |
| |
| if (user.profile) { |
| const p = user.profile; |
| |
| |
| user.profile.education = Array.isArray(p.education) ? p.education : Object.values(p.education || {}); |
| user.profile.experience = Array.isArray(p.experience) ? p.experience : Object.values(p.experience || {}); |
| user.profile.certificates = Array.isArray(p.certificates) ? p.certificates : Object.values(p.certificates || {}); |
| user.profile.skills = Array.isArray(p.skills) ? p.skills : Object.values(p.skills || {}); |
| user.profile.projects = Array.isArray(p.projects) ? p.projects : Object.values(p.projects || {}); |
| user.profile.interests = Array.isArray(p.interests) ? p.interests : Object.values(p.interests || {}); |
| user.profile.customSections = Array.isArray(p.customSections) ? p.customSections : Object.values(p.customSections || {}); |
| user.profile.sectionOrder = Array.isArray(p.sectionOrder) ? p.sectionOrder : Object.values(p.sectionOrder || {}); |
| |
| |
| user.profile.sectionNames = p.sectionNames || {}; |
| user.profile.sectionVisibility = p.sectionVisibility || {}; |
| user.profile.sectionStyleSettings = p.sectionStyleSettings || {}; |
| user.profile.designSettings = p.designSettings || {}; |
| user.profile.aiBot = p.aiBot || { enabled: false, provider: 'mgzon' }; |
| user.profile.audio = p.audio || {}; |
| user.profile.theme = p.theme || {}; |
| user.profile.layout = p.layout || {}; |
| user.profile.header = p.header || {}; |
| user.profile.footer = p.footer || {}; |
| user.profile.seo = p.seo || {}; |
| user.profile.schema = p.schema || {}; |
| user.profile.socialLinks = p.socialLinks || {}; |
| } |
| |
| |
| const [followersCount, followingCount, postsCount] = await Promise.all([ |
| Follow.countDocuments({ followingId: user._id, status: 'accepted' }), |
| Follow.countDocuments({ followerId: user._id, status: 'accepted' }), |
| Post.countDocuments({ userId: user._id }) |
| ]); |
| |
| user.followersCount = followersCount || 0; |
| user.followingCount = followingCount || 0; |
| user.postsCount = postsCount || 0; |
|
|
| |
| const now = new Date(); |
| const storyCount = await Story.countDocuments({ |
| userId: user._id, |
| expiresAt: { $gt: now } |
| }); |
| user.hasStory = storyCount > 0; |
| |
| return user; |
| }, |
| myProfile: async (parent, args, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const user = await User.findById(context.user.userId).lean(); |
| if (!user) return null; |
| |
| |
| if (user.profile) { |
| const p = user.profile; |
| user.profile.education = Array.isArray(p.education) ? p.education : Object.values(p.education || {}); |
| user.profile.experience = Array.isArray(p.experience) ? p.experience : Object.values(p.experience || {}); |
| user.profile.certificates = Array.isArray(p.certificates) ? p.certificates : Object.values(p.certificates || {}); |
| user.profile.skills = Array.isArray(p.skills) ? p.skills : Object.values(p.skills || {}); |
| user.profile.projects = Array.isArray(p.projects) ? p.projects : Object.values(p.projects || {}); |
| user.profile.interests = Array.isArray(p.interests) ? p.interests : Object.values(p.interests || {}); |
| user.profile.customSections = Array.isArray(p.customSections) ? p.customSections : Object.values(p.customSections || {}); |
| user.profile.sectionOrder = Array.isArray(p.sectionOrder) ? p.sectionOrder : Object.values(p.sectionOrder || {}); |
| |
| user.profile.sectionNames = p.sectionNames || {}; |
| user.profile.sectionVisibility = p.sectionVisibility || {}; |
| user.profile.sectionStyleSettings = p.sectionStyleSettings || {}; |
| user.profile.designSettings = p.designSettings || {}; |
| user.profile.aiBot = p.aiBot || { enabled: false, provider: 'mgzon' }; |
| user.profile.audio = p.audio || {}; |
| user.profile.theme = p.theme || {}; |
| user.profile.layout = p.layout || {}; |
| user.profile.header = p.header || {}; |
| user.profile.footer = p.footer || {}; |
| user.profile.seo = p.seo || {}; |
| user.profile.schema = p.schema || {}; |
| user.profile.socialLinks = p.socialLinks || {}; |
| } |
| |
| const [followersCount, followingCount, postsCount] = await Promise.all([ |
| Follow.countDocuments({ followingId: user._id, status: 'accepted' }), |
| Follow.countDocuments({ followerId: user._id, status: 'accepted' }), |
| Post.countDocuments({ userId: user._id }) |
| ]); |
| |
| user.followersCount = followersCount || 0; |
| user.followingCount = followingCount || 0; |
| user.postsCount = postsCount || 0; |
| |
| return user; |
| }, |
|
|
| userStats: async (parent, { userId }) => { |
| const [posts, projects, jobs, followers, following, applications, savedPosts, savedJobs] = await Promise.all([ |
| Post.countDocuments({ userId }), |
| Project.countDocuments({ userId }), |
| Job.countDocuments({ employerId: userId }), |
| Follow.countDocuments({ followingId: userId, status: 'accepted' }), |
| Follow.countDocuments({ followerId: userId, status: 'accepted' }), |
| JobApplication.countDocuments({ applicantId: userId }), |
| Post.countDocuments({ 'saves.userId': userId }), |
| JobSave.countDocuments({ userId }) |
| ]); |
| |
| const likesResult = await Post.aggregate([ |
| { $match: { userId: new mongoose.Types.ObjectId(userId) } }, |
| { $project: { likesCount: { $size: '$likes' } } }, |
| { $group: { _id: null, total: { $sum: '$likesCount' } } } |
| ]); |
| |
| return { |
| posts, |
| projects, |
| jobs, |
| followers, |
| following, |
| applications, |
| savedPosts, |
| savedJobs, |
| totalLikes: likesResult[0]?.total || 0 |
| }; |
| }, |
|
|
|
|
|
|
| |
| |
| |
|
|
| |
| profilePage: async (parent, { nickname }, context) => { |
| const userId = context.user?.userId; |
| |
| |
| let profileResult = null; |
| try { |
| profileResult = await Query.profile(parent, { nickname }, context); |
| } catch (e) { |
| console.error('Error loading profile:', e); |
| throw new GraphQLError('User not found'); |
| } |
| |
| if (!profileResult) throw new GraphQLError('User not found'); |
| |
| const profileUserId = profileResult._id.toString(); |
| const isOwner = userId === profileUserId; |
| |
| |
| let stats = { posts: 0, projects: 0, jobs: 0, followers: 0, following: 0, applications: 0, savedPosts: 0, savedJobs: 0, totalLikes: 0 }; |
| try { |
| stats = await Query.userStats(parent, { userId: profileUserId }); |
| } catch (e) { |
| console.error('Error loading stats:', e); |
| } |
| |
| |
| let ratings = { |
| data: [], |
| pagination: { page: 1, limit: 1, total: 0, pages: 0, hasNext: false, hasPrev: false }, |
| averageRating: 0, |
| totalRatings: 0 |
| }; |
| try { |
| ratings = await Query.userRatings(parent, { userId: profileUserId, page: 1, limit: 1 }); |
| } catch (e) { |
| console.error('Error loading ratings:', e); |
| } |
| |
| |
| let interactions = []; |
| try { |
| interactions = await getProfileInteractions(profileUserId, 1, 4); |
| } catch (e) { |
| console.error('Error loading interactions:', e); |
| } |
| |
| |
| let jobApplications = { |
| data: [], |
| pagination: { page: 1, limit: 5, total: 0, pages: 0, hasNext: false, hasPrev: false } |
| }; |
| if (isOwner) { |
| try { |
| jobApplications = await Query.myJobApplications(parent, { page: 1, limit: 5 }, context); |
| } catch (e) { |
| console.error('Error loading job applications:', e); |
| } |
| } |
| |
| |
| let contributionGraph = { total: 0, weeks: [], year: 2026, months: [], weekDays: [] }; |
| try { |
| contributionGraph = await getContributionGraph(profileUserId, 2026); |
| } catch (e) { |
| console.error('Error loading contribution graph:', e); |
| } |
| |
| |
| let notifications = { |
| data: [], |
| pagination: { page: 1, limit: 10, total: 0, pages: 0, hasNext: false, hasPrev: false }, |
| unreadCount: 0 |
| }; |
| if (isOwner) { |
| try { |
| notifications = await Query.notifications(parent, { page: 1, limit: 10 }, context); |
| } catch (e) { |
| console.error('Error loading notifications:', e); |
| } |
| } |
| |
| |
| let analytics = null; |
| if (isOwner) { |
| try { |
| analytics = await getProfileAnalytics(profileUserId); |
| } catch (e) { |
| console.error('Error loading analytics:', e); |
| } |
| } |
| |
| |
| let aiStatus = { enabled: false }; |
| try { |
| aiStatus = await Query.aiStatus(parent, { nickname }); |
| } catch (e) { |
| console.error('Error loading AI status:', e); |
| } |
| |
| |
| let followStatus = null; |
| if (userId && !isOwner) { |
| try { |
| followStatus = await Query.followStatus(parent, { userId: profileUserId }, context); |
| } catch (e) { |
| console.error('Error loading follow status:', e); |
| } |
| } |
| |
| |
| let alsoViewed = []; |
| try { |
| alsoViewed = await getAlsoViewed(profileUserId); |
| } catch (e) { |
| console.error('Error loading also viewed:', e); |
| } |
| |
| |
| let pageSuggestions = []; |
| try { |
| pageSuggestions = await getPageSuggestions(2); |
| } catch (e) { |
| console.error('Error loading page suggestions:', e); |
| } |
| |
| |
| let unreadCount = 0; |
| if (isOwner) { |
| try { |
| unreadCount = await Query.unreadNotificationsCount(parent, {}, context); |
| } catch (e) { |
| console.error('Error loading unread count:', e); |
| } |
| } |
| |
| |
| let storeSettings = null; |
| let subscription = null; |
| if (isOwner) { |
| try { |
| storeSettings = await Query.storeSettings(parent, {}, context); |
| subscription = await UserSubscription.findOne({ |
| userId: profileUserId, |
| status: 'active' |
| }).populate('planId'); |
| } catch (e) { |
| console.error('Error loading store settings:', e); |
| } |
| } |
|
|
| |
| if (profileResult.profile) { |
| profileResult.profile.storeEnabled = storeSettings?.settings?.enabled || false; |
| profileResult.profile.averageRating = storeSettings?.settings?.averageRating || 0; |
| profileResult.profile.totalRatings = storeSettings?.settings?.totalRatings || 0; |
| } |
|
|
| |
| profileResult.permissions = { |
| canCreateStore: !!(storeSettings?.settings?.enabled), |
| canCreateProducts: !!(subscription?.planId?.features?.storeEnabled) |
| }; |
| |
| |
| let subscriptionStatus = { |
| hasActiveSubscription: false, |
| canCreateStore: false, |
| message: 'No active subscription', |
| planName: null, |
| daysLeft: 0 |
| }; |
| try { |
| subscriptionStatus = await getSubscriptionStatus(profileUserId); |
| } catch (e) { |
| console.error('Error loading subscription status:', e); |
| } |
| |
| return { |
| profile: profileResult, |
| stats, |
| ratings, |
| interactions, |
| jobApplications, |
| contributionGraph, |
| notifications, |
| analytics, |
| aiStatus, |
| followStatus, |
| alsoViewed, |
| pageSuggestions, |
| unreadCount, |
| storeSettings, |
| subscriptionStatus |
| }; |
| }, |
|
|
| |
| contributionGraph: async (parent, { userId, year }) => { |
| return await getContributionGraph(userId, year || 2026); |
| }, |
|
|
| |
| userInteractions: async (parent, { nickname, page = 1, limit = 4 }) => { |
| const user = await User.findOne({ |
| $or: [ |
| { 'profile.nickname': { $regex: `^${nickname}$`, $options: 'i' } }, |
| { username: { $regex: `^${nickname}$`, $options: 'i' } } |
| ] |
| }); |
| if (!user) return []; |
| |
| return await getProfileInteractions(user._id, page, limit); |
| }, |
|
|
| |
| alsoViewed: async (parent, { userId }) => { |
| return await getAlsoViewed(userId); |
| }, |
|
|
| |
| pageSuggestions: async (parent, { limit = 2 }) => { |
| return await getPageSuggestions(limit); |
| }, |
|
|
| |
| profileAnalytics: async (parent, args, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| return await getProfileAnalytics(context.user.userId); |
| }, |
|
|
|
|
|
|
| |
| |
| |
|
|
| userSuggestions: async (parent, { limit = 3 }, context) => { |
| |
| if (!context.user) { |
| return []; |
| } |
|
|
| try { |
| |
| const following = await Follow.find({ |
| followerId: context.user.userId, |
| status: 'accepted' |
| }).select('followingId'); |
|
|
| const followingIds = following.map(f => f.followingId); |
| followingIds.push(context.user.userId); |
|
|
| |
| const suggestions = await User.aggregate([ |
| { |
| $match: { |
| _id: { $nin: followingIds }, |
| 'profile.isPublic': true |
| } |
| }, |
| { |
| $lookup: { |
| from: 'follows', |
| localField: '_id', |
| foreignField: 'followingId', |
| as: 'followers' |
| } |
| }, |
| { |
| $addFields: { |
| followersCount: { $size: '$followers' } |
| } |
| }, |
| { |
| $sample: { size: parseInt(limit) || 3 } |
| }, |
| { |
| $project: { |
| _id: 1, |
| username: 1, |
| followersCount: 1, |
| 'profile.nickname': 1, |
| 'profile.avatar': 1, |
| 'profile.jobTitle': 1, |
| 'profile.bio': 1 |
| } |
| } |
| ]); |
|
|
| return suggestions; |
|
|
| } catch (error) { |
| console.error('Error fetching user suggestions:', error); |
| return []; |
| } |
| }, |
|
|
| storeStatus: async (parent, args, context) => { |
| |
| if (!context.user) { |
| return { enabled: false }; |
| } |
|
|
| try { |
| |
| const store = await StoreSettings.findOne( |
| { userId: context.user.userId } |
| ).select('enabled'); |
|
|
| return { enabled: store?.enabled || false }; |
| } catch (error) { |
| console.error('Error fetching store status:', error); |
| return { enabled: false }; |
| } |
| }, |
| |
| searchUsers: async (parent, { query, page = 1, limit = 20 }) => { |
| if (query.length < 2) { |
| return { data: [], pagination: { page: 1, limit: 20, total: 0, pages: 0, hasNext: false, hasPrev: false } }; |
| } |
| |
| const skip = (page - 1) * limit; |
| const searchQuery = { |
| $or: [ |
| { 'profile.nickname': { $regex: query, $options: 'i' } }, |
| { username: { $regex: query, $options: 'i' } }, |
| { 'profile.jobTitle': { $regex: query, $options: 'i' } }, |
| { 'profile.bio': { $regex: query, $options: 'i' } } |
| ] |
| }; |
| |
| const [users, total] = await Promise.all([ |
| User.find(searchQuery) |
| .select('username profile email createdAt') |
| .skip(skip) |
| .limit(limit) |
| .lean(), |
| User.countDocuments(searchQuery) |
| ]); |
| |
| return { |
| data: users, |
| pagination: { |
| page, |
| limit, |
| total, |
| pages: Math.ceil(total / limit), |
| hasNext: skip + limit < total, |
| hasPrev: page > 1 |
| } |
| }; |
| }, |
|
|
| searchGlobal: async (parent, { query, limit = 5 }) => { |
| if (query.length < 2) { |
| return { products: [], stores: [], users: [], jobs: [], posts: [], total: { products: 0, stores: 0, users: 0, jobs: 0, posts: 0 } }; |
| } |
| |
| const regex = new RegExp(query, 'i'); |
| const lim = Math.min(limit, 20); |
| |
| const [products, stores, users, jobs, posts] = await Promise.all([ |
| Product.find({ |
| isActive: true, |
| $or: [{ title: regex }, { description: regex }, { tags: regex }] |
| }) |
| .limit(lim) |
| .populate('userId', 'username profile.nickname') |
| .lean(), |
| |
| StoreSettings.find({ |
| enabled: true, |
| $or: [{ storeName: regex }, { storeDescription: regex }] |
| }) |
| .limit(lim) |
| .populate('userId', 'username profile.nickname profile.avatar') |
| .lean(), |
| |
| User.find({ |
| $or: [ |
| { username: regex }, |
| { 'profile.nickname': regex }, |
| { 'profile.jobTitle': regex }, |
| { 'profile.skills.name': regex } |
| ] |
| }) |
| .limit(lim) |
| .select('username profile.nickname profile.avatar profile.jobTitle profile.bio') |
| .lean(), |
| |
| Job.find({ |
| status: 'active', |
| $or: [{ title: regex }, { description: regex }, { companyName: regex }] |
| }) |
| .limit(lim) |
| .select('title companyName location jobType salaryMin salaryMax isRemote') |
| .lean(), |
| |
| Post.find({ |
| visibility: 'public', |
| $or: [{ content: regex }, { tags: regex }] |
| }) |
| .limit(lim) |
| .populate('userId', 'username profile.nickname profile.avatar') |
| .sort({ createdAt: -1 }) |
| .lean() |
| ]); |
| |
| return { |
| products: products.map(p => ({ |
| _id: p._id, |
| title: p.title, |
| price: p.price, |
| type: p.type, |
| image: p.images?.[0] || null, |
| storeNickname: p.userId?.profile?.nickname || p.userId?.username, |
| url: `/product.html?id=${p._id}&owner=${p.userId?.profile?.nickname || p.userId?.username}` |
| })), |
| |
| stores: stores.map(s => ({ |
| _id: s._id, |
| storeName: s.storeName || s.userId?.profile?.nickname || s.userId?.username, |
| storeLogo: s.storeLogo || null, |
| nickname: s.userId?.profile?.nickname || s.userId?.username, |
| productsCount: s.productsCount || 0, |
| followersCount: s.followersCount || 0, |
| averageRating: s.averageRating || 0, |
| url: `/shop/${s.userId?.profile?.nickname || s.userId?.username}` |
| })), |
| |
| users: users.map(u => ({ |
| _id: u._id, |
| name: u.profile?.nickname || u.username, |
| avatar: u.profile?.avatar || '/assets/img/default-avatar.png', |
| jobTitle: u.profile?.jobTitle || 'Professional', |
| bio: u.profile?.bio?.substring(0, 100) || '', |
| url: `/profile/${u.profile?.nickname || u.username}` |
| })), |
| |
| jobs: jobs.map(j => ({ |
| _id: j._id, |
| title: j.title, |
| company: j.companyName, |
| location: j.location, |
| jobType: j.jobType, |
| isRemote: j.isRemote || false, |
| salary: j.salaryMin ? `${j.salaryMin} - ${j.salaryMax}` : null, |
| url: `/job-details.html?id=${j._id}` |
| })), |
| |
| posts: posts.map(p => ({ |
| _id: p._id, |
| content: p.content?.slice(0, 150) + (p.content?.length > 150 ? '...' : ''), |
| image: p.images?.[0]?.url || null, |
| author: { |
| name: p.userId?.profile?.nickname || p.userId?.username, |
| avatar: p.userId?.profile?.avatar || '/assets/img/default-avatar.png' |
| }, |
| likesCount: p.likes?.length || 0, |
| createdAt: p.createdAt, |
| url: `/post.html?id=${p._id}` |
| })), |
| |
| total: { |
| products: products.length, |
| stores: stores.length, |
| users: users.length, |
| jobs: jobs.length, |
| posts: posts.length |
| } |
| }; |
| }, |
|
|
| |
| |
| |
|
|
| postsFeed: async (parent, { page = 1, limit = 20, type }, context) => { |
| |
| if (!context.user) { |
| return { |
| data: [], |
| pagination: { |
| page: 1, |
| limit: 20, |
| total: 0, |
| pages: 0, |
| hasNext: false, |
| hasPrev: false |
| } |
| }; |
| } |
| |
| const skip = (page - 1) * limit; |
| const following = await Follow.find({ |
| followerId: context.user.userId, |
| status: 'accepted' |
| }).select('followingId'); |
| |
| const followingIds = following.map(f => f.followingId); |
| followingIds.push(context.user.userId); |
| |
| let query = { |
| userId: { $in: followingIds }, |
| visibility: { $in: ['public', 'followers'] } |
| }; |
| |
| if (type === 'popular') { |
| query = { ...query, 'likes.1': { $exists: true } }; |
| } |
| |
| const [posts, total] = await Promise.all([ |
| Post.find(query) |
| .sort({ pinned: -1, createdAt: -1 }) |
| .skip(skip) |
| .limit(limit) |
| .populate('userId', 'username profile.nickname profile.avatar') |
| .lean(), |
| Post.countDocuments(query) |
| ]); |
|
|
| |
| const userIds = posts.map(p => p.userId._id); |
| const usersWithStories = await Story.aggregate([ |
| { |
| $match: { |
| userId: { $in: userIds }, |
| expiresAt: { $gt: new Date() } |
| } |
| }, |
| { $group: { _id: '$userId', count: { $sum: 1 } } } |
| ]); |
|
|
| const userHasStoryMap = {}; |
| usersWithStories.forEach(u => { |
| userHasStoryMap[u._id.toString()] = true; |
| }); |
|
|
| |
| const postsWithSharedData = await Promise.all(posts.map(async (post) => { |
| let sharedFrom = post.sharedFrom || null; |
| |
| if (sharedFrom && sharedFrom.originalPostId) { |
| const originalPost = await Post.findById(sharedFrom.originalPostId) |
| .populate('userId', 'username profile.nickname profile.avatar profile.jobTitle') |
| .lean(); |
| |
| if (originalPost) { |
| sharedFrom = { |
| originalPostId: originalPost._id, |
| originalAuthorId: originalPost.userId?._id || null, |
| originalAuthorName: originalPost.userId?.profile?.nickname || originalPost.userId?.username || 'Unknown', |
| originalAuthorAvatar: originalPost.userId?.profile?.avatar || null, |
| originalAuthorJobTitle: originalPost.userId?.profile?.jobTitle || null, |
| originalContent: originalPost.content || '', |
| originalImages: originalPost.images || [], |
| originalVideo: originalPost.video || null, |
| originalCreatedAt: originalPost.createdAt || null, |
| originalLikesCount: originalPost.likes?.length || 0, |
| originalCommentsCount: originalPost.comments?.length || 0, |
| originalSharesCount: originalPost.shares?.length || 0, |
| sharedAt: post.sharedFrom?.sharedAt || null |
| }; |
| } |
| } |
| |
| return { |
| ...post, |
| sharedFrom |
| }; |
| })); |
| |
| |
| return { |
| data: postsWithSharedData.map(post => ({ |
| ...post, |
| sharedFrom: post.sharedFrom || null, |
| likesCount: (post.likes || []).length, |
| commentsCount: (post.comments || []).length, |
| sharesCount: (post.shares || []).length, |
| savesCount: (post.saves || []).length, |
| isLiked: post.likes?.some(l => l.userId?.toString() === context.user.userId) || false, |
| isSaved: post.saves?.some(s => s.userId?.toString() === context.user.userId) || false, |
| userId: { |
| ...post.userId, |
| hasStory: userHasStoryMap[post.userId._id.toString()] || false |
| } |
| })), |
| pagination: { |
| page, |
| limit, |
| total, |
| pages: Math.ceil(total / limit), |
| hasNext: skip + limit < total, |
| hasPrev: page > 1 |
| } |
| }; |
| }, |
| post: async (parent, { id }, context) => { |
| |
| const result = await Post.aggregate([ |
| { $match: { _id: new mongoose.Types.ObjectId(id) } }, |
| { |
| $lookup: { |
| from: 'users', |
| localField: 'userId', |
| foreignField: '_id', |
| as: 'userId' |
| } |
| }, |
| { $unwind: { path: '$userId', preserveNullAndEmptyArrays: true } }, |
| { |
| $lookup: { |
| from: 'users', |
| localField: 'comments.userId', |
| foreignField: '_id', |
| as: 'commentsUser' |
| } |
| }, |
| { |
| $lookup: { |
| from: 'users', |
| localField: 'comments.replies.userId', |
| foreignField: '_id', |
| as: 'repliesUser' |
| } |
| } |
| ]); |
| |
| if (!result || result.length === 0) { |
| throw new GraphQLError('Post not found'); |
| } |
| |
| const post = result[0]; |
| |
| |
| if (post.comments && post.comments.length > 0) { |
| post.comments = post.comments.map(c => { |
| |
| const commentUser = post.commentsUser?.find(u => u._id.toString() === c.userId?.toString()); |
| |
| |
| const replies = (c.replies || []).map(r => { |
| const replyUser = post.repliesUser?.find(u => u._id.toString() === r.userId?.toString()); |
| return { |
| ...r, |
| userId: replyUser || { |
| _id: r.userId || 'unknown', |
| username: 'Unknown', |
| profile: { nickname: 'Unknown', avatar: '/assets/img/default-avatar.png', jobTitle: '' } |
| }, |
| likesCount: r.likes?.length || 0, |
| isLiked: r.likes?.some(l => l.userId?.toString() === context.user?.userId) || false, |
| isEdited: r.isEdited || false, |
| edited: r.edited || false |
| }; |
| }); |
| |
| return { |
| ...c, |
| userId: commentUser || { |
| _id: c.userId || 'unknown', |
| username: 'Unknown', |
| profile: { nickname: 'Unknown', avatar: '/assets/img/default-avatar.png', jobTitle: '' } |
| }, |
| likesCount: c.likes?.length || 0, |
| isLiked: c.likes?.some(l => l.userId?.toString() === context.user?.userId) || false, |
| replies: replies, |
| isEdited: c.isEdited || false, |
| edited: c.edited || false |
| }; |
| }); |
| } |
| |
| |
| delete post.commentsUser; |
| delete post.repliesUser; |
| |
| |
| |
| |
| let sharedFrom = post.sharedFrom || null; |
| |
| if (sharedFrom && sharedFrom.originalPostId) { |
| const originalPost = await Post.findById(sharedFrom.originalPostId) |
| .populate('userId', 'username profile.nickname profile.avatar profile.jobTitle') |
| .lean(); |
| |
| if (originalPost) { |
| sharedFrom = { |
| originalPostId: originalPost._id, |
| originalAuthorId: originalPost.userId?._id || null, |
| originalAuthorName: originalPost.userId?.profile?.nickname || originalPost.userId?.username || 'Unknown', |
| originalAuthorAvatar: originalPost.userId?.profile?.avatar || null, |
| originalAuthorJobTitle: originalPost.userId?.profile?.jobTitle || null, |
| originalContent: originalPost.content || '', |
| originalImages: originalPost.images || [], |
| originalVideo: originalPost.video || null, |
| originalCreatedAt: originalPost.createdAt || null, |
| originalLikesCount: originalPost.likes?.length || 0, |
| originalCommentsCount: originalPost.comments?.length || 0, |
| originalSharesCount: originalPost.shares?.length || 0, |
| sharedAt: post.sharedFrom?.sharedAt || null |
| }; |
| } |
| } |
| |
| |
| return { |
| ...post, |
| userId: post.userId || { _id: post.userId, username: 'Unknown', profile: {} }, |
| sharedFrom, |
| likesCount: post.likes?.length || 0, |
| commentsCount: post.comments?.length || 0, |
| sharesCount: post.shares?.length || 0, |
| savesCount: post.saves?.length || 0, |
| isLiked: context.user ? post.likes?.some(l => l.userId?.toString() === context.user.userId) : false, |
| isSaved: context.user ? post.saves?.some(s => s.userId?.toString() === context.user.userId) : false |
| }; |
| }, |
|
|
| userPosts: async (parent, { userId, page = 1, limit = 20 }, context) => { |
| const skip = (page - 1) * limit; |
| const isOwner = context.user && context.user.userId === userId; |
| |
| let visibilityFilter = {}; |
| if (!isOwner) { |
| visibilityFilter = { visibility: { $in: ['public', 'followers'] } }; |
| } |
| |
| const [posts, total] = await Promise.all([ |
| Post.find({ userId, ...visibilityFilter }) |
| .sort({ pinned: -1, createdAt: -1 }) |
| .skip(skip) |
| .limit(limit) |
| .populate('userId', 'username profile.nickname profile.avatar') |
| .lean(), |
| Post.countDocuments({ userId, ...visibilityFilter }) |
| ]); |
| |
| |
| const sharedPostIds = posts |
| .filter(p => p.sharedFrom?.originalPostId) |
| .map(p => p.sharedFrom.originalPostId); |
| |
| let originalPostsMap = new Map(); |
| if (sharedPostIds.length > 0) { |
| const originalPosts = await Post.find({ |
| _id: { $in: sharedPostIds } |
| }) |
| .populate('userId', 'username profile.nickname profile.avatar profile.jobTitle') |
| .lean(); |
| |
| originalPosts.forEach(op => { |
| originalPostsMap.set(op._id.toString(), { |
| _id: op._id, |
| content: op.content || '', |
| images: op.images || [], |
| video: op.video || null, |
| userId: op.userId, |
| createdAt: op.createdAt, |
| likesCount: op.likes?.length || 0, |
| commentsCount: op.comments?.length || 0, |
| sharesCount: op.shares?.length || 0 |
| }); |
| }); |
| } |
| |
| return { |
| data: posts.map(post => { |
| |
| let sharedFrom = null; |
| |
| |
| if (post.sharedFrom && post.sharedFrom.originalPostId) { |
| const originalPost = originalPostsMap.get(post.sharedFrom.originalPostId.toString()); |
| if (originalPost) { |
| sharedFrom = { |
| originalPostId: post.sharedFrom.originalPostId, |
| originalAuthorId: originalPost.userId?._id || null, |
| originalAuthorName: post.sharedFrom.originalAuthorName || 'Unknown', |
| originalAuthorAvatar: originalPost.userId?.profile?.avatar || null, |
| originalAuthorJobTitle: originalPost.userId?.profile?.jobTitle || null, |
| originalContent: originalPost.content || '', |
| originalImages: originalPost.images || [], |
| originalVideo: originalPost.video || null, |
| originalCreatedAt: originalPost.createdAt || null, |
| originalLikesCount: originalPost.likesCount || 0, |
| originalCommentsCount: originalPost.commentsCount || 0, |
| originalSharesCount: originalPost.sharesCount || 0, |
| sharedAt: post.sharedFrom.sharedAt || null |
| }; |
| } |
| } |
| |
| return { |
| ...post, |
| sharedFrom, |
| likesCount: post.likes?.length || 0, |
| commentsCount: post.comments?.length || 0, |
| sharesCount: post.shares?.length || 0, |
| savesCount: post.saves?.length || 0, |
| isLiked: post.likes?.some(l => l.userId?.toString() === context.user?.userId) || false, |
| isSaved: post.saves?.some(s => s.userId?.toString() === context.user?.userId) || false |
| }; |
| }), |
| pagination: { |
| page, |
| limit, |
| total, |
| pages: Math.ceil(total / limit), |
| hasNext: skip + limit < total, |
| hasPrev: page > 1 |
| } |
| }; |
| }, |
|
|
| savedPosts: async (parent, { page = 1, limit = 20 }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const skip = (page - 1) * limit; |
| const [posts, total] = await Promise.all([ |
| Post.find({ 'saves.userId': context.user.userId }) |
| .sort({ 'saves.savedAt': -1 }) |
| .skip(skip) |
| .limit(limit) |
| .populate('userId', 'username profile.nickname profile.avatar') |
| .lean(), |
| Post.countDocuments({ 'saves.userId': context.user.userId }) |
| ]); |
| |
| |
| return { |
| data: posts.map(post => ({ |
| ...post, |
| sharedFrom: post.sharedFrom || null, |
| likesCount: (post.likes || []).length, |
| commentsCount: (post.comments || []).length, |
| sharesCount: (post.shares || []).length, |
| savesCount: (post.saves || []).length, |
| isSaved: true, |
| isLiked: post.likes?.some(l => l.userId?.toString() === context.user.userId) || false |
| })), |
| pagination: { |
| page, |
| limit, |
| total, |
| pages: Math.ceil(total / limit), |
| hasNext: skip + limit < total, |
| hasPrev: page > 1 |
| } |
| }; |
| }, |
|
|
| suggestedPosts: async (parent, { limit = 5 }, context) => { |
| if (!context.user) return []; |
| |
| const following = await Follow.find({ |
| followerId: context.user.userId, |
| status: 'accepted' |
| }).select('followingId'); |
| |
| const followingIds = following.map(f => f.followingId); |
| followingIds.push(context.user.userId); |
| |
| const posts = await Post.find({ |
| userId: { $nin: followingIds }, |
| visibility: { $in: ['public', 'followers'] } |
| }) |
| .sort({ createdAt: -1 }) |
| .limit(limit) |
| .populate('userId', 'username profile.nickname profile.avatar profile.jobTitle') |
| .lean(); |
| |
| |
| return posts.map(post => ({ |
| ...post, |
| sharedFrom: post.sharedFrom || null, |
| likesCount: (post.likes || []).length, |
| commentsCount: (post.comments || []).length, |
| sharesCount: (post.shares || []).length, |
| savesCount: (post.saves || []).length, |
| isLiked: post.likes?.some(l => l.userId?.toString() === context.user.userId) || false, |
| isSaved: post.saves?.some(s => s.userId?.toString() === context.user.userId) || false |
| })); |
| }, |
|
|
| searchPosts: async (parent, { query, page = 1, limit = 20 }, context) => { |
| if (query.length < 2) { |
| return { data: [], pagination: { page: 1, limit: 20, total: 0, pages: 0, hasNext: false, hasPrev: false } }; |
| } |
| |
| const skip = (page - 1) * limit; |
| const searchQuery = { |
| $or: [ |
| { content: { $regex: query, $options: 'i' } }, |
| { tags: { $in: [new RegExp(query, 'i')] } } |
| ], |
| visibility: { $in: ['public', 'followers'] } |
| }; |
| |
| const [posts, total] = await Promise.all([ |
| Post.find(searchQuery) |
| .sort({ createdAt: -1 }) |
| .skip(skip) |
| .limit(limit) |
| .populate('userId', 'username profile.nickname profile.avatar') |
| .lean(), |
| Post.countDocuments(searchQuery) |
| ]); |
| |
| |
| return { |
| data: posts.map(post => ({ |
| ...post, |
| sharedFrom: post.sharedFrom || null, |
| likesCount: (post.likes || []).length, |
| commentsCount: (post.comments || []).length, |
| sharesCount: (post.shares || []).length, |
| savesCount: (post.saves || []).length, |
| isLiked: post.likes?.some(l => l.userId?.toString() === context.user.userId) || false, |
| isSaved: post.saves?.some(s => s.userId?.toString() === context.user.userId) || false |
| })), |
| pagination: { |
| page, |
| limit, |
| total, |
| pages: Math.ceil(total / limit), |
| hasNext: skip + limit < total, |
| hasPrev: page > 1 |
| } |
| }; |
| }, |
|
|
| |
| comments: async (parent, { projectId, page = 1, limit = 20 }, context) => { |
| const skip = (page - 1) * limit; |
| |
| const mainComments = await Comment.find({ |
| projectId: new mongoose.Types.ObjectId(projectId), |
| parentCommentId: null, |
| hidden: false |
| }) |
| .populate('userId', 'username profile.nickname profile.avatar') |
| .sort({ timestamp: -1 }) |
| .skip(skip) |
| .limit(limit); |
| |
| const commentsWithReplies = await Promise.all(mainComments.map(async (comment) => { |
| const replies = await Comment.find({ |
| parentCommentId: comment._id, |
| hidden: false |
| }) |
| .populate('userId', 'username profile.nickname profile.avatar') |
| .sort({ timestamp: 1 }); |
| |
| return { |
| ...comment.toObject(), |
| replies, |
| repliesCount: replies.length, |
| likesCount: comment.likes?.length || 0, |
| isLiked: context.user ? comment.likes?.some(id => id.toString() === context.user.userId) : false |
| }; |
| })); |
| |
| const total = await Comment.countDocuments({ |
| projectId: new mongoose.Types.ObjectId(projectId), |
| parentCommentId: null, |
| hidden: false |
| }); |
| |
| return { |
| data: commentsWithReplies, |
| pagination: { |
| page, |
| limit, |
| total, |
| pages: Math.ceil(total / limit), |
| hasNext: skip + limit < total, |
| hasPrev: page > 1 |
| } |
| }; |
| }, |
|
|
| comment: async (parent, { id }, context) => { |
| const comment = await Comment.findById(id) |
| .populate('userId', 'username profile.nickname profile.avatar') |
| .populate('projectId') |
| .populate('projectOwnerId', 'username profile.nickname'); |
| |
| if (!comment) throw new GraphQLError('Comment not found'); |
| if (comment.hidden && !context.isAdmin) { |
| throw new GraphQLError('This comment is hidden'); |
| } |
| |
| const replies = await Comment.find({ |
| parentCommentId: comment._id, |
| hidden: false |
| }) |
| .populate('userId', 'username profile.nickname profile.avatar') |
| .sort({ timestamp: 1 }); |
| |
| return { |
| ...comment.toObject(), |
| replies, |
| repliesCount: replies.length, |
| likesCount: comment.likes?.length || 0, |
| isLiked: context.user ? comment.likes?.some(id => id.toString() === context.user.userId) : false |
| }; |
| }, |
|
|
| userComments: async (parent, { userId, page = 1, limit = 20 }, context) => { |
| const skip = (page - 1) * limit; |
| const isOwner = context.user && context.user.userId === userId; |
| |
| let query = { userId, hidden: false }; |
| if (!isOwner) { |
| query.visibility = 'public'; |
| } |
| |
| const comments = await Comment.find(query) |
| .populate('userId', 'username profile.nickname profile.avatar') |
| .populate('projectId') |
| .sort({ timestamp: -1 }) |
| .skip(skip) |
| .limit(limit); |
| |
| const total = await Comment.countDocuments(query); |
| |
| const enrichedComments = comments.map(comment => ({ |
| ...comment.toObject(), |
| likesCount: comment.likes?.length || 0, |
| isLiked: context.user ? comment.likes?.some(id => id.toString() === context.user.userId) : false |
| })); |
| |
| return { |
| data: enrichedComments, |
| pagination: { |
| page, |
| limit, |
| total, |
| pages: Math.ceil(total / limit), |
| hasNext: skip + limit < total, |
| hasPrev: page > 1 |
| } |
| }; |
| }, |
|
|
| reportedComments: async (parent, args, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| const reportedComments = await Comment.find({ |
| reportCount: { $gt: 0 }, |
| hidden: { $ne: true } |
| }) |
| .sort({ reportCount: -1, timestamp: -1 }) |
| .populate('userId', 'username email profile.nickname') |
| .populate('projectId'); |
| |
| return reportedComments; |
| }, |
|
|
|
|
| |
| getPostComments: async (parent, { postId, page = 1, limit = 50 }, context) => { |
| const post = await Post.findById(postId); |
| if (!post) throw new GraphQLError('Post not found'); |
| |
| const skip = (page - 1) * limit; |
| const comments = post.comments || []; |
| |
| |
| const paginatedComments = comments.slice(skip, skip + limit); |
| |
| |
| const commentData = await Promise.all(paginatedComments.map(async (comment) => { |
| const user = await User.findById(comment.userId).select('username profile.nickname profile.avatar'); |
| const likesCount = comment.likes?.length || 0; |
| const isLiked = comment.likes?.some(id => id.toString() === context.user?.userId) || false; |
| |
| return { |
| _id: comment._id, |
| userId: user || { _id: comment.userId, username: 'Unknown', profile: {} }, |
| text: comment.text, |
| createdAt: comment.createdAt, |
| likesCount, |
| isLiked, |
| edited: comment.edited || false, |
| editedAt: comment.editedAt || null, |
| hidden: comment.hidden || false |
| }; |
| })); |
| |
| return { |
| data: commentData, |
| pagination: { |
| page, |
| limit, |
| total: comments.length, |
| pages: Math.ceil(comments.length / limit), |
| hasNext: skip + limit < comments.length, |
| hasPrev: page > 1 |
| } |
| }; |
| }, |
|
|
| |
| |
| |
|
|
| |
|
|
| |
| storiesFeed: async (parent, { limit = 20 }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const following = await Follow.find({ |
| followerId: context.user.userId, |
| status: 'accepted' |
| }).select('followingId'); |
| |
| const followingIds = following.map(f => f.followingId); |
| followingIds.push(context.user.userId); |
| |
| const stories = await Story.find({ |
| userId: { $in: followingIds }, |
| expiresAt: { $gt: new Date() }, |
| $or: [ |
| { visibility: 'public' }, |
| { visibility: 'followers' }, |
| { visibility: 'only-me', userId: context.user.userId } |
| ] |
| }) |
| .sort({ createdAt: -1 }) |
| .limit(limit * 2) |
| .populate('userId') |
| .lean(); |
| |
| const storiesByUser = {}; |
| stories.forEach(story => { |
| const userId = story.userId._id.toString(); |
| if (!storiesByUser[userId]) { |
| storiesByUser[userId] = { |
| user: story.userId, |
| stories: [] |
| }; |
| } |
| const hasViewed = story.views?.some(v => v.userId?.toString() === context.user.userId) || false; |
| storiesByUser[userId].stories.push({ |
| ...story, |
| viewsCount: (story.views || []).length, |
| reactionsCount: (story.reactions || []).length, |
| hasViewed: hasViewed, |
| textColor: story.textColor || '#ffffff', |
| userId: story.userId, |
| allowDownload: story.allowDownload || false, |
| filter: story.filter || 'none', |
| filterIntensity: story.filterIntensity || 0.5, |
| filterApplied: story.filterApplied || false, |
| audio: story.audio || { url: null, publicId: null, duration: 0 }, |
|
|
|
|
| textStyle: story.textStyle || { |
| fontSize: 32, |
| fontFamily: 'Inter', |
| fontWeight: '700', |
| textAlign: 'center', |
| position: { x: 50, y: 50 }, |
| rotation: 0, |
| opacity: 1, |
| textShadow: '0 2px 10px rgba(0,0,0,0.5)', |
| background: 'transparent', |
| padding: '0', |
| borderRadius: '0', |
| maxWidth: 90 |
| } |
|
|
| }); |
| }); |
| |
| return Object.values(storiesByUser); |
| }, |
|
|
| |
| storiesAll: async (parent, { page = 1, limit = 20 }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const skip = (page - 1) * limit; |
| |
| const following = await Follow.find({ |
| followerId: context.user.userId, |
| status: 'accepted' |
| }).select('followingId'); |
| |
| const followingIds = following.map(f => f.followingId.toString()); |
| |
| const allStories = await Story.find({ |
| expiresAt: { $gt: new Date() }, |
| $or: [ |
| { visibility: 'public' }, |
| { userId: { $in: followingIds }, visibility: { $in: ['public', 'followers'] } }, |
| { userId: context.user.userId } |
| ] |
| }) |
| .sort({ createdAt: -1 }) |
| .skip(skip) |
| .limit(limit) |
| .populate('userId') |
| .lean(); |
| |
| const storiesMap = {}; |
| const userIds = new Set(); |
| |
| allStories.forEach(story => { |
| const userId = story.userId._id.toString(); |
| userIds.add(userId); |
| |
| if (!storiesMap[userId]) { |
| storiesMap[userId] = { |
| user: story.userId, |
| stories: [], |
| isFollowing: followingIds.includes(userId), |
| isCurrentUser: userId === context.user.userId |
| }; |
| } |
| const hasViewed = story.views?.some(v => v.userId?.toString() === context.user.userId) || false; |
| storiesMap[userId].stories.push({ |
| ...story, |
| viewsCount: (story.views || []).length, |
| reactionsCount: (story.reactions || []).length, |
| hasViewed: hasViewed, |
| textColor: story.textColor || '#ffffff', |
| userId: story.userId, |
| allowDownload: story.allowDownload || false, |
| filter: story.filter || 'none', |
| filterIntensity: story.filterIntensity || 0.5, |
| filterApplied: story.filterApplied || false, |
| audio: story.audio || { url: null, publicId: null, duration: 0 }, |
|
|
| textStyle: story.textStyle || { |
| fontSize: 32, |
| fontFamily: 'Inter', |
| fontWeight: '700', |
| textAlign: 'center', |
| position: { x: 50, y: 50 }, |
| rotation: 0, |
| opacity: 1, |
| textShadow: '0 2px 10px rgba(0,0,0,0.5)', |
| background: 'transparent', |
| padding: '0', |
| borderRadius: '0', |
| maxWidth: 90 |
| } |
|
|
| }); |
| }); |
| |
| const sortedGroups = Object.values(storiesMap).sort((a, b) => { |
| if (a.isFollowing && !b.isFollowing) return -1; |
| if (!a.isFollowing && b.isFollowing) return 1; |
| if (a.isCurrentUser && !b.isCurrentUser) return -1; |
| if (!a.isCurrentUser && b.isCurrentUser) return 1; |
| return b.stories.length - a.stories.length; |
| }); |
| |
| const paginatedData = sortedGroups.slice(0, limit); |
| const total = sortedGroups.length; |
| const totalStories = allStories.length; |
| const followingStories = allStories.filter(s => |
| followingIds.includes(s.userId._id.toString()) |
| ).length; |
| |
| return { |
| data: paginatedData, |
| pagination: { |
| page, |
| limit, |
| total, |
| pages: Math.ceil(total / limit), |
| hasNext: skip + limit < total, |
| hasPrev: page > 1 |
| }, |
| stats: { |
| totalStories, |
| myStories: allStories.filter(s => s.userId._id.toString() === context.user.userId).length, |
| followingStories, |
| discoverStories: totalStories - followingStories, |
| followingCount: followingIds.length |
| } |
| }; |
| }, |
|
|
| |
| storiesDiscover: async (parent, { limit = 10 }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const following = await Follow.find({ |
| followerId: context.user.userId, |
| status: 'accepted' |
| }).select('followingId'); |
| |
| const followingIds = following.map(f => f.followingId); |
| followingIds.push(context.user.userId); |
| |
| const discoverStories = await Story.find({ |
| userId: { $nin: followingIds }, |
| visibility: 'public', |
| expiresAt: { $gt: new Date() } |
| }) |
| .sort({ createdAt: -1 }) |
| .limit(parseInt(limit)) |
| .populate('userId') |
| .lean(); |
| |
| const storiesByUser = {}; |
| discoverStories.forEach(story => { |
| const userId = story.userId._id.toString(); |
| if (!storiesByUser[userId]) { |
| storiesByUser[userId] = { |
| user: story.userId, |
| stories: [], |
| isFollowing: false |
| }; |
| } |
| const hasViewed = story.views?.some(v => v.userId?.toString() === context.user.userId) || false; |
| storiesByUser[userId].stories.push({ |
| ...story, |
| viewsCount: (story.views || []).length, |
| reactionsCount: (story.reactions || []).length, |
| hasViewed: hasViewed, |
| textColor: story.textColor || '#ffffff', |
| userId: story.userId, |
| allowDownload: story.allowDownload || false, |
| filter: story.filter || 'none', |
| filterIntensity: story.filterIntensity || 0.5, |
| filterApplied: story.filterApplied || false, |
| audio: story.audio || { url: null, publicId: null, duration: 0 }, |
|
|
| textStyle: story.textStyle || { |
| fontSize: 32, |
| fontFamily: 'Inter', |
| fontWeight: '700', |
| textAlign: 'center', |
| position: { x: 50, y: 50 }, |
| rotation: 0, |
| opacity: 1, |
| textShadow: '0 2px 10px rgba(0,0,0,0.5)', |
| background: 'transparent', |
| padding: '0', |
| borderRadius: '0', |
| maxWidth: 90 |
| } |
|
|
| }); |
| }); |
| |
| return Object.values(storiesByUser); |
| }, |
|
|
| |
| storyStats: async (parent, args, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const following = await Follow.find({ |
| followerId: context.user.userId, |
| status: 'accepted' |
| }).select('followingId'); |
| |
| const followingIds = following.map(f => f.followingId); |
| |
| const [totalStories, myStories, followingStories, discoverStories] = await Promise.all([ |
| Story.countDocuments({ expiresAt: { $gt: new Date() } }), |
| Story.countDocuments({ userId: context.user.userId, expiresAt: { $gt: new Date() } }), |
| Story.countDocuments({ |
| userId: { $in: followingIds }, |
| expiresAt: { $gt: new Date() }, |
| visibility: { $in: ['public', 'followers'] } |
| }), |
| Story.countDocuments({ |
| userId: { $nin: [...followingIds, context.user.userId] }, |
| expiresAt: { $gt: new Date() }, |
| visibility: 'public' |
| }) |
| ]); |
| |
| return { |
| totalStories, |
| myStories, |
| followingStories, |
| discoverStories, |
| followingCount: followingIds.length |
| }; |
| }, |
|
|
| |
| userStories: async (parent, { userId }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const isOwner = userId === context.user.userId; |
| const isFollowing = await Follow.exists({ |
| followerId: context.user.userId, |
| followingId: userId, |
| status: 'accepted' |
| }); |
| |
| let visibilityQuery = { userId, expiresAt: { $gt: new Date() } }; |
| |
| if (!isOwner) { |
| visibilityQuery.$or = [ |
| { visibility: 'public' }, |
| { visibility: 'followers' } |
| ]; |
| if (!isFollowing) { |
| visibilityQuery.visibility = 'public'; |
| } |
| } |
| |
| const stories = await Story.find(visibilityQuery) |
| .sort({ createdAt: -1 }) |
| .populate('userId') |
| .lean(); |
| |
| return stories.map(story => ({ |
| ...story, |
| viewsCount: (story.views || []).length, |
| reactionsCount: (story.reactions || []).length, |
| hasViewed: story.views?.some(v => v.userId?.toString() === context.user.userId) || false, |
| userId: story.userId, |
| textColor: story.textColor || '#ffffff', |
| allowDownload: story.allowDownload || false, |
| filter: story.filter || 'none', |
| filterIntensity: story.filterIntensity || 0.5, |
| filterApplied: story.filterApplied || false, |
| audio: story.audio || { url: null, publicId: null, duration: 0 }, |
|
|
| textStyle: story.textStyle || { |
| fontSize: 32, |
| fontFamily: 'Inter', |
| fontWeight: '700', |
| textAlign: 'center', |
| position: { x: 50, y: 50 }, |
| rotation: 0, |
| opacity: 1, |
| textShadow: '0 2px 10px rgba(0,0,0,0.5)', |
| background: 'transparent', |
| padding: '0', |
| borderRadius: '0', |
| maxWidth: 90 |
| } |
|
|
| })); |
| }, |
|
|
| |
| storyViewers: async (parent, { storyId, page = 1, limit = 20 }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const story = await Story.findById(storyId); |
| if (!story) throw new GraphQLError('Story not found'); |
| |
| if (story.userId.toString() !== context.user.userId) { |
| throw new GraphQLError('You are not authorized to view viewers of this story'); |
| } |
| |
| const skip = (page - 1) * limit; |
| const views = story.views || []; |
| const total = views.length; |
| |
| const viewerIds = views.map(v => v.userId); |
| const paginatedIds = viewerIds.slice(skip, skip + limit); |
| |
| const viewers = await User.find({ |
| _id: { $in: paginatedIds } |
| }); |
| |
| const viewersWithTime = paginatedIds.map((id) => { |
| const user = viewers.find(u => u._id.toString() === id.toString()); |
| const view = views.find(v => v.userId.toString() === id.toString()); |
| return { |
| userId: user || { _id: id, username: 'Unknown', profile: {} }, |
| viewedAt: view?.viewedAt || new Date() |
| }; |
| }); |
| |
| return { |
| data: viewersWithTime, |
| total, |
| pagination: { |
| page, |
| limit, |
| total, |
| pages: Math.ceil(total / limit), |
| hasNext: skip + limit < total, |
| hasPrev: page > 1 |
| } |
| }; |
| }, |
|
|
| |
| story: async (parent, { id }, context) => { |
| const story = await Story.findById(id) |
| .populate('userId') |
| .lean(); |
| |
| if (!story) throw new GraphQLError('Story not found'); |
| |
| if (story.expiresAt && new Date(story.expiresAt) < new Date()) { |
| throw new GraphQLError('Story has expired'); |
| } |
| |
| const isOwner = context.user && context.user.userId === story.userId._id.toString(); |
| const isFollowing = context.user ? await Follow.exists({ |
| followerId: context.user.userId, |
| followingId: story.userId._id, |
| status: 'accepted' |
| }) : false; |
| |
| if (!isOwner && story.visibility === 'only-me') { |
| throw new GraphQLError('This story is private'); |
| } |
| if (story.visibility === 'followers' && !isOwner && !isFollowing) { |
| throw new GraphQLError('This story is only visible to followers'); |
| } |
| |
| return { |
| ...story, |
| viewsCount: (story.views || []).length, |
| reactionsCount: (story.reactions || []).length, |
| hasViewed: story.views?.some(v => v.userId?.toString() === context.user.userId) || false, |
| userId: story.userId, |
| textColor: story.textColor || '#ffffff', |
| allowDownload: story.allowDownload || false, |
| filter: story.filter || 'none', |
| filterIntensity: story.filterIntensity || 0.5, |
| filterApplied: story.filterApplied || false, |
| audio: story.audio || { url: null, publicId: null, duration: 0 }, |
|
|
| textStyle: story.textStyle || { |
| fontSize: 32, |
| fontFamily: 'Inter', |
| fontWeight: '700', |
| textAlign: 'center', |
| position: { x: 50, y: 50 }, |
| rotation: 0, |
| opacity: 1, |
| textShadow: '0 2px 10px rgba(0,0,0,0.5)', |
| background: 'transparent', |
| padding: '0', |
| borderRadius: '0', |
| maxWidth: 90 |
| } |
|
|
| }; |
| }, |
|
|
| |
| |
| |
|
|
| |
| videosFeed: async (parent, { page = 1, limit = 20, type = 'latest' }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const skip = (page - 1) * limit; |
| |
| |
| let query = { |
| 'video.url': { $exists: true, $ne: null }, |
| visibility: { $in: ['public', 'followers'] } |
| }; |
| |
| let sort = { createdAt: -1 }; |
| |
| if (type === 'trending') { |
| |
| sort = { viewsCount: -1, likesCount: -1 }; |
| } else if (type === 'following') { |
| |
| const following = await Follow.find({ |
| followerId: context.user.userId, |
| status: 'accepted' |
| }).select('followingId'); |
| const followingIds = following.map(f => f.followingId); |
| followingIds.push(context.user.userId); |
| query.userId = { $in: followingIds }; |
| } |
| |
| |
| const videos = await Post.find(query) |
| .sort(sort) |
| .skip(skip) |
| .limit(limit) |
| .populate('userId', 'username profile.nickname profile.avatar profile.jobTitle profile.bio') |
| .lean(); |
| |
| |
| const videosWithStats = await Promise.all(videos.map(async (video) => { |
| const viewsCount = video.views?.length || 0; |
| const likesCount = video.likes?.length || 0; |
| const commentsCount = video.comments?.length || 0; |
| const sharesCount = video.shares?.length || 0; |
| const savesCount = video.saves?.length || 0; |
| |
| const isLiked = video.likes?.some(l => l.userId?.toString() === context.user.userId) || false; |
| const isSaved = video.saves?.some(s => s.userId?.toString() === context.user.userId) || false; |
| |
| |
| let isFollowing = false; |
| if (video.userId && context.user) { |
| isFollowing = await Follow.exists({ |
| followerId: context.user.userId, |
| followingId: video.userId._id, |
| status: 'accepted' |
| }); |
| } |
| |
| return { |
| ...video, |
| viewsCount, |
| likesCount, |
| commentsCount, |
| sharesCount, |
| savesCount, |
| isLiked, |
| isSaved, |
| isFollowing: !!isFollowing |
| }; |
| })); |
| |
| |
| const trending = await Post.find({ |
| 'video.url': { $exists: true, $ne: null }, |
| visibility: 'public' |
| }) |
| .sort({ viewsCount: -1, likesCount: -1 }) |
| .limit(5) |
| .populate('userId', 'username profile.nickname profile.avatar') |
| .lean() |
| .then(videos => videos.map(v => ({ |
| ...v, |
| viewsCount: v.views?.length || 0, |
| likesCount: v.likes?.length || 0 |
| }))); |
| |
| const total = await Post.countDocuments(query); |
| |
| return { |
| data: videosWithStats, |
| pagination: { |
| page, |
| limit, |
| total, |
| pages: Math.ceil(total / limit), |
| hasNext: skip + limit < total, |
| hasPrev: page > 1 |
| }, |
| trending |
| }; |
| }, |
|
|
| |
| video: async (parent, { id }, context) => { |
| const video = await Post.findOne({ |
| _id: id, |
| 'video.url': { $exists: true, $ne: null } |
| }) |
| .populate('userId', 'username profile.nickname profile.avatar profile.jobTitle profile.bio') |
| .lean(); |
| |
| if (!video) throw new GraphQLError('Video not found'); |
| |
| |
| if (context.user) { |
| Post.findByIdAndUpdate(id, { |
| $push: { views: { userId: context.user.userId, viewedAt: new Date() } } |
| }).catch(() => {}); |
| } |
| |
| const viewsCount = video.views?.length || 0; |
| const likesCount = video.likes?.length || 0; |
| const commentsCount = video.comments?.length || 0; |
| const sharesCount = video.shares?.length || 0; |
| const savesCount = video.saves?.length || 0; |
| |
| const isLiked = video.likes?.some(l => l.userId?.toString() === context.user?.userId) || false; |
| const isSaved = video.saves?.some(s => s.userId?.toString() === context.user?.userId) || false; |
| |
| let isFollowing = false; |
| if (video.userId && context.user) { |
| isFollowing = await Follow.exists({ |
| followerId: context.user.userId, |
| followingId: video.userId._id, |
| status: 'accepted' |
| }); |
| } |
| |
| return { |
| ...video, |
| viewsCount, |
| likesCount, |
| commentsCount, |
| sharesCount, |
| savesCount, |
| isLiked, |
| isSaved, |
| isFollowing: !!isFollowing |
| }; |
| }, |
| |
| |
| jobs: async (parent, { page = 1, limit = 20, search, category, jobType, location }) => { |
| const skip = (page - 1) * limit; |
| let query = { status: 'active', deadline: { $gt: new Date() } }; |
| |
| if (search) query.$text = { $search: search }; |
| if (category) query.category = category; |
| if (jobType) query.jobType = jobType; |
| if (location) query.location = { $regex: location, $options: 'i' }; |
| |
| const [jobs, total, featured] = await Promise.all([ |
| Job.find(query) |
| .sort({ postedAt: -1 }) |
| .skip(skip) |
| .limit(limit) |
| .populate('employerId', 'username profile.nickname profile.avatar') |
| .lean(), |
| Job.countDocuments(query), |
| Job.find({ ...query, isFeatured: true, featuredUntil: { $gt: new Date() } }) |
| .sort({ postedAt: -1 }) |
| .limit(3) |
| .populate('employerId', 'username profile.nickname profile.avatar') |
| .lean() |
| ]); |
| |
| return { |
| data: jobs, |
| featured, |
| pagination: { |
| page, |
| limit, |
| total, |
| pages: Math.ceil(total / limit), |
| hasNext: skip + limit < total, |
| hasPrev: page > 1 |
| } |
| }; |
| }, |
|
|
| job: async (parent, { id }, context) => { |
| const job = await Job.findById(id) |
| .populate('employerId', 'username profile.nickname profile.avatar profile.bio profile.phone') |
| .lean(); |
| |
| if (!job) throw new GraphQLError('Job not found'); |
| |
| |
| Job.findByIdAndUpdate(id, { $inc: { views: 1 } }).catch(() => {}); |
| |
| let hasApplied = false; |
| let applicationStatus = null; |
| let isSaved = false; |
| |
| if (context.user) { |
| const application = await JobApplication.findOne({ |
| jobId: id, |
| applicantId: context.user.userId |
| }); |
| if (application) { |
| hasApplied = true; |
| applicationStatus = application.status; |
| } |
| |
| const saved = await JobSave.findOne({ |
| jobId: id, |
| userId: context.user.userId |
| }); |
| isSaved = !!saved; |
| } |
| |
| return { |
| ...job, |
| hasApplied, |
| applicationStatus, |
| isSaved |
| }; |
| }, |
|
|
| myJobs: async (parent, { page = 1, limit = 20, status }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const skip = (page - 1) * limit; |
| let query = { employerId: context.user.userId }; |
| if (status) query.status = status; |
| |
| const [jobs, total] = await Promise.all([ |
| Job.find(query) |
| .sort({ postedAt: -1 }) |
| .skip(skip) |
| .limit(limit) |
| .lean(), |
| Job.countDocuments(query) |
| ]); |
| |
| const jobsWithStats = await Promise.all(jobs.map(async (job) => { |
| const applicationsCount = await JobApplication.countDocuments({ jobId: job._id }); |
| const pendingCount = await JobApplication.countDocuments({ jobId: job._id, status: 'pending' }); |
| return { |
| ...job, |
| applicationsCount, |
| pendingCount |
| }; |
| })); |
| |
| return { |
| data: jobsWithStats, |
| pagination: { |
| page, |
| limit, |
| total, |
| pages: Math.ceil(total / limit), |
| hasNext: skip + limit < total, |
| hasPrev: page > 1 |
| } |
| }; |
| }, |
|
|
| myJobApplications: async (parent, { page = 1, limit = 20, status }, context) => { |
| |
| if (!context.user) { |
| return { |
| data: [], |
| pagination: { |
| page: 1, |
| limit: 20, |
| total: 0, |
| pages: 0, |
| hasNext: false, |
| hasPrev: false |
| } |
| }; |
| } |
| |
| const skip = (page - 1) * limit; |
| let query = { applicantId: context.user.userId }; |
| if (status) query.status = status; |
| |
| const [applications, total] = await Promise.all([ |
| JobApplication.find(query) |
| .sort({ appliedAt: -1 }) |
| .skip(skip) |
| .limit(limit) |
| .populate('jobId', 'title companyName location jobType salaryMin salaryMax deadline status') |
| .lean(), |
| JobApplication.countDocuments(query) |
| ]); |
| |
| |
| return { |
| data: applications || [], |
| pagination: { |
| page: page || 1, |
| limit: limit || 20, |
| total: total || 0, |
| pages: Math.ceil((total || 0) / (limit || 20)), |
| hasNext: skip + (limit || 20) < (total || 0), |
| hasPrev: page > 1 |
| } |
| }; |
| }, |
|
|
|
|
| jobApplications: async (parent, { jobId, page = 1, limit = 20, status }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const job = await Job.findById(jobId); |
| if (!job) throw new GraphQLError('Job not found'); |
| |
| if (job.employerId.toString() !== context.user.userId && !context.isAdmin) { |
| throw new GraphQLError('Unauthorized to view applications for this job'); |
| } |
| |
| const skip = (page - 1) * limit; |
| let query = { jobId }; |
| if (status) query.status = status; |
| |
| const [applications, total] = await Promise.all([ |
| JobApplication.find(query) |
| .sort({ appliedAt: -1 }) |
| .skip(skip) |
| .limit(limit) |
| .populate('applicantId', 'username profile.nickname profile.avatar profile.bio profile.skills') |
| .lean(), |
| JobApplication.countDocuments(query) |
| ]); |
| |
| return { |
| data: applications, |
| pagination: { |
| page, |
| limit, |
| total, |
| pages: Math.ceil(total / limit), |
| hasNext: skip + limit < total, |
| hasPrev: page > 1 |
| } |
| }; |
| }, |
|
|
| jobApplication: async (parent, { id }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const application = await JobApplication.findById(id) |
| .populate('jobId') |
| .populate('applicantId', 'username profile.nickname profile.avatar profile.bio profile.skills profile.education profile.experience') |
| .lean(); |
| |
| if (!application) throw new GraphQLError('Application not found'); |
| |
| const isOwner = application.applicantId._id.toString() === context.user.userId; |
| const isEmployer = application.jobId.employerId.toString() === context.user.userId; |
| |
| if (!isOwner && !isEmployer && !context.isAdmin) { |
| throw new GraphQLError('Unauthorized to view this application'); |
| } |
| |
| return application; |
| }, |
|
|
| savedJobs: async (parent, { page = 1, limit = 20 }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const skip = (page - 1) * limit; |
| const savedJobs = await JobSave.find({ userId: context.user.userId }) |
| .sort({ savedAt: -1 }) |
| .skip(skip) |
| .limit(limit) |
| .populate('jobId') |
| .lean(); |
| |
| const jobs = savedJobs.map(s => s.jobId).filter(j => j); |
| const total = await JobSave.countDocuments({ userId: context.user.userId }); |
| |
| return { |
| data: jobs, |
| pagination: { |
| page, |
| limit, |
| total, |
| pages: Math.ceil(total / limit), |
| hasNext: skip + limit < total, |
| hasPrev: page > 1 |
| } |
| }; |
| }, |
|
|
| jobCategories: async (parent, { lang = 'en' }, context) => { |
| const categories = await JobCategory.find({ isActive: true }).sort({ order: 1 }); |
| |
| const categoriesWithStats = await Promise.all(categories.map(async (cat) => { |
| const count = await Job.countDocuments({ |
| category: cat.name, |
| status: 'active', |
| deadline: { $gt: new Date() } |
| }); |
| |
| return { |
| ...cat.toObject(), |
| displayName: lang === 'ar' ? (cat.nameAr || cat.name) : cat.name, |
| count |
| }; |
| })); |
| |
| return categoriesWithStats; |
| }, |
|
|
| jobStats: async () => { |
| const [totalJobs, totalCompanies, remoteJobsCount] = await Promise.all([ |
| Job.countDocuments({ status: 'active', deadline: { $gt: new Date() } }), |
| Job.distinct('employerId').then(arr => arr.length), |
| Job.countDocuments({ isRemote: true, status: 'active', deadline: { $gt: new Date() } }) |
| ]); |
| |
| const topCategories = await Job.aggregate([ |
| { $match: { status: 'active', deadline: { $gt: new Date() } } }, |
| { $group: { _id: '$category', count: { $sum: 1 } } }, |
| { $sort: { count: -1 } }, |
| { $limit: 5 } |
| ]); |
| |
| return { |
| totalJobs, |
| totalCompanies, |
| remoteJobs: remoteJobsCount, |
| topCategories: topCategories.map(c => ({ category: c._id, count: c.count })) |
| }; |
| }, |
|
|
| |
| myStore: async (parent, args, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const storeSettings = await StoreSettings.findOne({ userId: context.user.userId }); |
| if (!storeSettings) { |
| return { |
| _id: context.user.userId, |
| userId: context.user.userId, |
| enabled: false, |
| currency: 'USD', |
| currencySymbol: '$', |
| productsCount: 0, |
| followersCount: 0, |
| averageRating: 0, |
| totalSales: 0, |
| totalOrders: 0, |
| createdAt: new Date(), |
| updatedAt: new Date() |
| }; |
| } |
| |
| const user = await User.findById(context.user.userId); |
| |
| return { |
| _id: context.user.userId, |
| userId: context.user.userId, |
| storeName: storeSettings.storeName || user?.profile?.nickname || user?.username, |
| storeLogo: storeSettings.storeLogo, |
| storeBanner: storeSettings.storeBanner, |
| storeDescription: storeSettings.storeDescription, |
| contactEmail: storeSettings.contactEmail || user?.email, |
| enabled: storeSettings.enabled || false, |
| currency: storeSettings.currency || 'USD', |
| currencySymbol: storeSettings.currencySymbol || '$', |
| productsCount: await Product.countDocuments({ userId: context.user.userId, isActive: true }), |
| followersCount: await StoreFollow.countDocuments({ storeId: context.user.userId }), |
| averageRating: storeSettings.averageRating || 0, |
| totalSales: 0, |
| totalOrders: 0, |
| createdAt: storeSettings.createdAt, |
| updatedAt: storeSettings.updatedAt |
| }; |
| }, |
|
|
| store: async (parent, { username }) => { |
| const user = await User.findOne({ |
| $or: [ |
| { 'profile.nickname': { $regex: `^${username}$`, $options: 'i' } }, |
| { username: { $regex: `^${username}$`, $options: 'i' } } |
| ] |
| }); |
| |
| if (!user) throw new GraphQLError('Store not found'); |
| |
| const storeSettings = await StoreSettings.findOne({ userId: user._id }); |
| if (!storeSettings || !storeSettings.enabled) { |
| throw new GraphQLError('Store is not active'); |
| } |
| |
| return { |
| _id: user._id, |
| userId: user._id, |
| storeName: storeSettings.storeName || user.profile?.nickname || user.username, |
| storeLogo: storeSettings.storeLogo, |
| storeBanner: storeSettings.storeBanner, |
| storeDescription: storeSettings.storeDescription, |
| contactEmail: storeSettings.contactEmail || user.email, |
| enabled: storeSettings.enabled, |
| currency: storeSettings.currency || 'USD', |
| currencySymbol: storeSettings.currencySymbol || '$', |
| productsCount: await Product.countDocuments({ userId: user._id, isActive: true }), |
| followersCount: await StoreFollow.countDocuments({ storeId: user._id }), |
| averageRating: storeSettings.averageRating || 0, |
| totalSales: 0, |
| totalOrders: 0, |
| createdAt: storeSettings.createdAt, |
| updatedAt: storeSettings.updatedAt |
| }; |
| }, |
|
|
| storeProducts: async (parent, { username, tags, limit = 50 }) => { |
| const user = await User.findOne({ |
| $or: [ |
| { 'profile.nickname': { $regex: `^${username}$`, $options: 'i' } }, |
| { username: { $regex: `^${username}$`, $options: 'i' } } |
| ] |
| }); |
| |
| if (!user) throw new GraphQLError('Store not found'); |
| |
| const storeSettings = await StoreSettings.findOne({ userId: user._id }); |
| if (!storeSettings || !storeSettings.enabled) { |
| throw new GraphQLError('Store is not active'); |
| } |
| |
| let query = { userId: user._id, isActive: true }; |
| if (tags && tags.length > 0) { |
| query.tags = { $in: tags }; |
| } |
| |
| const products = await Product.find(query) |
| .select('title description price type images tags salesCount averageRating reviews deliveryTime') |
| .sort({ createdAt: -1 }) |
| .limit(limit); |
| |
| return products; |
| }, |
|
|
| storeProduct: async (parent, { id }) => { |
| const product = await Product.findById(id) |
| .populate('userId', 'username profile.nickname profile.avatar') |
| .lean(); |
| |
| if (!product) throw new GraphQLError('Product not found'); |
| if (!product.isActive) throw new GraphQLError('Product is not active'); |
| |
| return product; |
| }, |
|
|
| myProducts: async (parent, args, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| return await Product.find({ userId: context.user.userId }) |
| .sort({ createdAt: -1 }); |
| }, |
|
|
| storePages: async (parent, { username }) => { |
| const user = await User.findOne({ |
| $or: [ |
| { 'profile.nickname': { $regex: `^${username}$`, $options: 'i' } }, |
| { username: { $regex: `^${username}$`, $options: 'i' } } |
| ] |
| }); |
| |
| if (!user) throw new GraphQLError('Store not found'); |
| |
| const defaultPages = [ |
| { slug: 'products', title: 'Products', type: 'default', isEnabled: true, order: 1 }, |
| { slug: 'about', title: 'About Us', type: 'default', isEnabled: true, order: 2 }, |
| { slug: 'contact', title: 'Contact', type: 'default', isEnabled: true, order: 3 } |
| ]; |
| |
| const customPages = await Page.find({ storeId: user._id, isEnabled: true }) |
| .sort({ order: 1 }) |
| .select('slug title type order'); |
| |
| return [...defaultPages, ...customPages]; |
| }, |
|
|
| storePage: async (parent, { username, slug }) => { |
| const user = await User.findOne({ |
| $or: [ |
| { 'profile.nickname': { $regex: `^${username}$`, $options: 'i' } }, |
| { username: { $regex: `^${username}$`, $options: 'i' } } |
| ] |
| }); |
| |
| if (!user) throw new GraphQLError('Store not found'); |
| |
| if (slug === 'products') { |
| return { |
| slug: 'products', |
| title: 'Products', |
| type: 'default', |
| isEnabled: true, |
| isDefault: true, |
| content: '', |
| css: '', |
| js: '' |
| }; |
| } |
| |
| if (slug === 'about') { |
| const storeSettings = await StoreSettings.findOne({ userId: user._id }); |
| return { |
| slug: 'about', |
| title: 'About Us', |
| type: 'default', |
| isEnabled: true, |
| isDefault: true, |
| content: storeSettings?.storeDescription || 'Welcome to our store!', |
| css: '', |
| js: '' |
| }; |
| } |
| |
| if (slug === 'contact') { |
| const storeSettings = await StoreSettings.findOne({ userId: user._id }); |
| return { |
| slug: 'contact', |
| title: 'Contact Us', |
| type: 'default', |
| isEnabled: true, |
| isDefault: true, |
| content: ` |
| <div class="contact-info"> |
| <p><strong>Email:</strong> ${storeSettings?.contactEmail || user.email}</p> |
| <p><strong>Store:</strong> ${storeSettings?.storeName || user.profile?.nickname || user.username}</p> |
| </div> |
| `, |
| css: '', |
| js: '' |
| }; |
| } |
| |
| const page = await Page.findOne({ storeId: user._id, slug, isEnabled: true }); |
| if (!page) throw new GraphQLError('Page not found'); |
| |
| return page; |
| }, |
|
|
| myPages: async (parent, args, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| return await Page.find({ storeId: context.user.userId }) |
| .sort({ order: 1 }); |
| }, |
|
|
| page: async (parent, { id }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const page = await Page.findOne({ _id: id, storeId: context.user.userId }); |
| if (!page) throw new GraphQLError('Page not found'); |
| |
| return page; |
| }, |
|
|
| storeSettings: async (parent, args, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| let settings = await StoreSettings.findOne({ userId: context.user.userId }); |
| if (!settings) { |
| settings = new StoreSettings({ userId: context.user.userId }); |
| await settings.save(); |
| } |
| |
| let theme = await StoreTheme.findOne({ userId: context.user.userId }); |
| if (!theme) { |
| theme = new StoreTheme({ userId: context.user.userId }); |
| await theme.save(); |
| } |
| |
| return { settings, theme }; |
| }, |
|
|
| storeAnalytics: async (parent, { username }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const user = await User.findOne({ |
| $or: [ |
| { 'profile.nickname': { $regex: `^${username}$`, $options: 'i' } }, |
| { username: { $regex: `^${username}$`, $options: 'i' } } |
| ] |
| }); |
| |
| if (!user) throw new GraphQLError('Store not found'); |
| if (user._id.toString() !== context.user.userId) { |
| throw new GraphQLError('Unauthorized'); |
| } |
| |
| let analytics = await ProfileAnalytics.findOne({ userId: user._id }); |
| if (!analytics) { |
| analytics = { totalViews: 0, uniqueViews: 0, profileViews: [], dailyStats: {}, monthlyStats: {}, referralSources: {} }; |
| } |
| |
| const ordersStats = await Order.aggregate([ |
| { $match: { sellerId: user._id } }, |
| { $group: { |
| _id: null, |
| totalOrders: { $sum: 1 }, |
| completedOrders: { $sum: { $cond: [{ $eq: ['$status', 'completed'] }, 1, 0] } }, |
| totalRevenue: { $sum: { $cond: [{ $eq: ['$status', 'completed'] }, '$totalAmount', 0] } }, |
| pendingOrders: { $sum: { $cond: [{ $eq: ['$status', 'pending'] }, 1, 0] } } |
| }} |
| ]); |
| |
| const productsStats = await Product.aggregate([ |
| { $match: { userId: user._id } }, |
| { $group: { |
| _id: '$type', |
| count: { $sum: 1 }, |
| totalSales: { $sum: '$salesCount' } |
| }} |
| ]); |
| |
| const followersCount = await StoreFollow.countDocuments({ storeId: user._id }); |
| |
| const topProducts = await Product.find({ |
| userId: user._id, |
| isActive: true, |
| salesCount: { $gt: 0 } |
| }) |
| .sort({ salesCount: -1 }) |
| .limit(5) |
| .select('title salesCount price images'); |
| |
| |
| const last7Days = []; |
| for (let i = 6; i >= 0; i--) { |
| const date = new Date(); |
| date.setDate(date.getDate() - i); |
| const dateStr = date.toISOString().split('T')[0]; |
| last7Days.push({ |
| date: dateStr, |
| views: analytics.dailyStats?.[dateStr]?.views || 0, |
| uniqueViews: analytics.dailyStats?.[dateStr]?.uniqueViews || 0, |
| orders: analytics.dailyStats?.[dateStr]?.orders || 0, |
| revenue: analytics.dailyStats?.[dateStr]?.revenue || 0 |
| }); |
| } |
| |
| |
| const last6Months = []; |
| for (let i = 5; i >= 0; i--) { |
| const date = new Date(); |
| date.setMonth(date.getMonth() - i); |
| const monthStr = date.toISOString().slice(0, 7); |
| last6Months.push({ |
| month: monthStr, |
| views: analytics.monthlyStats?.[monthStr]?.views || 0, |
| uniqueViews: analytics.monthlyStats?.[monthStr]?.uniqueViews || 0, |
| orders: analytics.monthlyStats?.[monthStr]?.orders || 0, |
| revenue: analytics.monthlyStats?.[monthStr]?.revenue || 0 |
| }); |
| } |
| |
| return { |
| totalViews: analytics.totalViews || 0, |
| uniqueViews: analytics.uniqueViews || 0, |
| followersCount, |
| orders: { |
| total: ordersStats[0]?.totalOrders || 0, |
| completed: ordersStats[0]?.completedOrders || 0, |
| pending: ordersStats[0]?.pendingOrders || 0, |
| totalRevenue: ordersStats[0]?.totalRevenue || 0 |
| }, |
| products: { |
| total: await Product.countDocuments({ userId: user._id, isActive: true }), |
| digital: productsStats.find(p => p._id === 'digital')?.count || 0, |
| projects: productsStats.find(p => p._id === 'project')?.count || 0, |
| services: productsStats.find(p => p._id === 'service')?.count || 0, |
| totalSales: productsStats.reduce((sum, p) => sum + p.totalSales, 0) |
| }, |
| topProducts: topProducts.map(p => ({ |
| _id: p._id, |
| title: p.title, |
| salesCount: p.salesCount, |
| price: p.price, |
| image: p.images?.[0] || null |
| })), |
| last7Days, |
| last6Months, |
| referralSources: analytics.referralSources || { |
| direct: 0, google: 0, facebook: 0, twitter: 0, linkedin: 0, |
| instagram: 0, github: 0, mgzon: 0, search: 0, other: 0 |
| }, |
| recentViews: (analytics.profileViews || []).slice(-10).reverse().map(view => ({ |
| viewerName: view.viewerName || 'Guest', |
| deviceType: view.deviceType || 'unknown', |
| browser: view.browser || 'unknown', |
| timestamp: view.timestamp, |
| referer: view.referer || 'direct' |
| })) |
| }; |
| }, |
|
|
| |
| cart: async (parent, args, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| let cart = await Cart.findOne({ userId: context.user.userId }); |
| if (!cart) { |
| cart = new Cart({ userId: context.user.userId, stores: new Map() }); |
| await cart.save(); |
| } |
| |
| return cart; |
| }, |
|
|
| cartSummary: async (parent, args, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const cart = await Cart.findOne({ userId: context.user.userId }); |
| if (!cart) { |
| return { totalItems: 0, totalStores: 0, stores: [] }; |
| } |
| |
| const stores = []; |
| for (let [storeId, storeData] of cart.stores) { |
| stores.push({ |
| storeId, |
| storeName: storeData.storeName, |
| itemCount: storeData.itemCount, |
| subtotal: storeData.subtotal |
| }); |
| } |
| |
| return { |
| totalItems: cart.totalItems, |
| totalStores: cart.totalStores, |
| stores |
| }; |
| }, |
|
|
| |
| myOrders: async (parent, { as, page = 1, limit = 20 }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const skip = (page - 1) * limit; |
| let query = {}; |
| |
| if (as === 'buyer') { |
| query.buyerId = context.user.userId; |
| } else if (as === 'seller') { |
| query.sellerId = context.user.userId; |
| } else { |
| query = { |
| $or: [ |
| { buyerId: context.user.userId }, |
| { sellerId: context.user.userId } |
| ] |
| }; |
| } |
| |
| const [orders, total] = await Promise.all([ |
| Order.find(query) |
| .sort({ createdAt: -1 }) |
| .skip(skip) |
| .limit(limit) |
| .populate('buyerId', 'username profile.nickname profile.avatar') |
| .populate('sellerId', 'username profile.nickname profile.avatar') |
| .lean(), |
| Order.countDocuments(query) |
| ]); |
| |
| return { |
| data: orders, |
| pagination: { |
| page, |
| limit, |
| total, |
| pages: Math.ceil(total / limit), |
| hasNext: skip + limit < total, |
| hasPrev: page > 1 |
| } |
| }; |
| }, |
|
|
| order: async (parent, { id }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const order = await Order.findById(id) |
| .populate('buyerId', 'username email profile.nickname') |
| .populate('sellerId', 'username profile.nickname') |
| .lean(); |
| |
| if (!order) throw new GraphQLError('Order not found'); |
| |
| if (order.buyerId._id.toString() !== context.user.userId && |
| order.sellerId._id.toString() !== context.user.userId && |
| !context.isAdmin) { |
| throw new GraphQLError('Unauthorized'); |
| } |
| |
| return order; |
| }, |
|
|
| |
| |
| |
|
|
| |
| followers: async (parent, { userId, page = 1, limit = 20 }, context) => { |
| const skip = (page - 1) * limit; |
| const followers = await Follow.find({ |
| followingId: userId, |
| status: 'accepted' |
| }) |
| .skip(skip) |
| .limit(limit) |
| .populate('followerId', 'username profile.nickname profile.avatar profile.jobTitle') |
| .lean(); |
| |
| const followersUsers = await Promise.all(followers.map(async (f) => ({ |
| _id: f.followerId._id, |
| username: f.followerId.username, |
| profile: f.followerId.profile, |
| isFollowing: context.user ? await Follow.exists({ |
| followerId: context.user.userId, |
| followingId: f.followerId._id, |
| status: 'accepted' |
| }) : false |
| }))); |
| |
| const total = await Follow.countDocuments({ followingId: userId, status: 'accepted' }); |
| |
| return { |
| data: followersUsers, |
| pagination: { |
| page, |
| limit, |
| total, |
| pages: Math.ceil(total / limit), |
| hasNext: skip + limit < total, |
| hasPrev: page > 1 |
| } |
| }; |
| }, |
|
|
| |
| following: async (parent, { userId, page = 1, limit = 20 }, context) => { |
| const skip = (page - 1) * limit; |
| const following = await Follow.find({ |
| followerId: userId, |
| status: 'accepted' |
| }) |
| .skip(skip) |
| .limit(limit) |
| .populate('followingId', 'username profile.nickname profile.avatar profile.jobTitle') |
| .lean(); |
| |
| const followingUsers = await Promise.all(following.map(async (f) => ({ |
| _id: f.followingId._id, |
| username: f.followingId.username, |
| profile: f.followingId.profile, |
| isFollowing: context.user ? await Follow.exists({ |
| followerId: context.user.userId, |
| followingId: f.followingId._id, |
| status: 'accepted' |
| }) : false |
| }))); |
| |
| const total = await Follow.countDocuments({ followerId: userId, status: 'accepted' }); |
| |
| return { |
| data: followingUsers, |
| pagination: { |
| page, |
| limit, |
| total, |
| pages: Math.ceil(total / limit), |
| hasNext: skip + limit < total, |
| hasPrev: page > 1 |
| } |
| }; |
| }, |
|
|
| |
| myFollowers: async (parent, { page = 1, limit = 20 }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const skip = (page - 1) * limit; |
| const followers = await Follow.find({ |
| followingId: context.user.userId, |
| status: 'accepted' |
| }) |
| .skip(skip) |
| .limit(limit) |
| .populate('followerId', 'username profile.nickname profile.avatar profile.jobTitle') |
| .lean(); |
| |
| const followersUsers = await Promise.all(followers.map(async (f) => ({ |
| _id: f.followerId._id, |
| username: f.followerId.username, |
| profile: f.followerId.profile, |
| isFollowing: await Follow.exists({ |
| followerId: context.user.userId, |
| followingId: f.followerId._id, |
| status: 'accepted' |
| }) |
| }))); |
| |
| const total = await Follow.countDocuments({ followingId: context.user.userId, status: 'accepted' }); |
| |
| return { |
| data: followersUsers, |
| pagination: { |
| page, |
| limit, |
| total, |
| pages: Math.ceil(total / limit), |
| hasNext: skip + limit < total, |
| hasPrev: page > 1 |
| } |
| }; |
| }, |
|
|
| |
| myFollowing: async (parent, { page = 1, limit = 20 }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const skip = (page - 1) * limit; |
| const following = await Follow.find({ |
| followerId: context.user.userId, |
| status: 'accepted' |
| }) |
| .skip(skip) |
| .limit(limit) |
| .populate('followingId', 'username profile.nickname profile.avatar profile.jobTitle') |
| .lean(); |
| |
| const followingUsers = await Promise.all(following.map(async (f) => ({ |
| _id: f.followingId._id, |
| username: f.followingId.username, |
| profile: f.followingId.profile, |
| isFollowing: await Follow.exists({ |
| followerId: context.user.userId, |
| followingId: f.followingId._id, |
| status: 'accepted' |
| }) |
| }))); |
| |
| const total = await Follow.countDocuments({ followerId: context.user.userId, status: 'accepted' }); |
| |
| return { |
| data: followingUsers, |
| pagination: { |
| page, |
| limit, |
| total, |
| pages: Math.ceil(total / limit), |
| hasNext: skip + limit < total, |
| hasPrev: page > 1 |
| } |
| }; |
| }, |
|
|
| |
| followRequests: async (parent, args, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const requests = await Follow.find({ |
| followingId: context.user.userId, |
| status: 'pending' |
| }).populate('followerId', 'username profile.nickname profile.avatar profile.jobTitle'); |
| |
| return requests.map(req => ({ |
| _id: req._id, |
| requester: req.followerId, |
| requestedAt: req.createdAt |
| })); |
| }, |
|
|
| |
| followStatus: async (parent, { userId }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const follow = await Follow.findOne({ |
| followerId: context.user.userId, |
| followingId: userId, |
| status: 'accepted' |
| }); |
| |
| return { isFollowing: !!follow }; |
| }, |
|
|
| |
| notifications: async (parent, { page = 1, limit = 30 }, context) => { |
| |
| if (!context.user) { |
| return { |
| data: [], |
| pagination: { |
| page: 1, |
| limit: 30, |
| total: 0, |
| pages: 0, |
| hasNext: false, |
| hasPrev: false |
| }, |
| unreadCount: 0 |
| }; |
| } |
| |
| const skip = (page - 1) * limit; |
| const [notifications, total] = await Promise.all([ |
| Notification.find({ userId: context.user.userId }) |
| .sort({ createdAt: -1 }) |
| .skip(skip) |
| .limit(limit) |
| .populate('actorId', 'username profile.nickname profile.avatar') |
| .lean(), |
| Notification.countDocuments({ userId: context.user.userId }) |
| ]); |
| |
| const unreadCount = await Notification.countDocuments({ |
| userId: context.user.userId, |
| read: false |
| }); |
| |
| |
| return { |
| data: notifications || [], |
| pagination: { |
| page: page || 1, |
| limit: limit || 30, |
| total: total || 0, |
| pages: Math.ceil((total || 0) / (limit || 30)), |
| hasNext: skip + (limit || 30) < (total || 0), |
| hasPrev: page > 1 |
| }, |
| unreadCount: unreadCount || 0 |
| }; |
| }, |
|
|
| unreadNotificationsCount: async (parent, args, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| return await Notification.countDocuments({ |
| userId: context.user.userId, |
| read: false |
| }); |
| }, |
|
|
| |
| userRatings: async (parent, { userId, page = 1, limit = 10 }) => { |
| |
| if (!userId) { |
| return { |
| data: [], |
| pagination: { |
| page: 1, |
| limit: 10, |
| total: 0, |
| pages: 0, |
| hasNext: false, |
| hasPrev: false |
| }, |
| averageRating: 0, |
| totalRatings: 0 |
| }; |
| } |
| |
| const skip = (page - 1) * limit; |
| const [ratings, total, allRatings] = await Promise.all([ |
| Rating.find({ targetUserId: userId }) |
| .sort({ createdAt: -1 }) |
| .skip(skip) |
| .limit(limit) |
| .populate('raterUserId', 'username profile.nickname profile.avatar profile.jobTitle') |
| .lean(), |
| Rating.countDocuments({ targetUserId: userId }), |
| Rating.find({ targetUserId: userId }) |
| ]); |
| |
| const averageRating = allRatings.length > 0 |
| ? allRatings.reduce((sum, r) => sum + r.rating, 0) / allRatings.length |
| : 0; |
| |
| |
| return { |
| data: ratings || [], |
| pagination: { |
| page: page || 1, |
| limit: limit || 10, |
| total: total || 0, |
| pages: Math.ceil((total || 0) / (limit || 10)), |
| hasNext: skip + (limit || 10) < (total || 0), |
| hasPrev: page > 1 |
| }, |
| averageRating: averageRating || 0, |
| totalRatings: total || 0 |
| }; |
| }, |
|
|
| myRating: async (parent, { targetUserId }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const rating = await Rating.findOne({ |
| targetUserId, |
| raterUserId: context.user.userId |
| }); |
| |
| return rating; |
| }, |
|
|
| |
| aiConversations: async (parent, { page = 1, limit = 50 }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const skip = (page - 1) * limit; |
| const [data, total] = await Promise.all([ |
| AIConversation.find({ userId: context.user.userId }) |
| .sort({ timestamp: -1 }) |
| .skip(skip) |
| .limit(limit), |
| AIConversation.countDocuments({ userId: context.user.userId }) |
| ]); |
| |
| return { |
| data, |
| pagination: { |
| page, |
| limit, |
| total, |
| pages: Math.ceil(total / limit), |
| hasNext: skip + limit < total, |
| hasPrev: page > 1 |
| } |
| }; |
| }, |
|
|
| aiStats: async (parent, args, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const [total, last7Days, providerStats, avgResponseTime] = await Promise.all([ |
| AIConversation.countDocuments({ userId: context.user.userId }), |
| AIConversation.aggregate([ |
| { $match: { userId: context.user.userId, timestamp: { $gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) } } }, |
| { $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$timestamp' } }, count: { $sum: 1 } } }, |
| { $sort: { _id: 1 } } |
| ]), |
| AIConversation.aggregate([ |
| { $match: { userId: context.user.userId } }, |
| { $group: { _id: '$provider', count: { $sum: 1 } } } |
| ]), |
| AIConversation.aggregate([ |
| { $match: { userId: context.user.userId, responseTime: { $gt: 0 } } }, |
| { $group: { _id: null, avg: { $avg: '$responseTime' } } } |
| ]) |
| ]); |
| |
| const user = await User.findById(context.user.userId).select('profile.aiBot'); |
| const aiConfig = user?.profile?.aiBot || {}; |
| |
| return { |
| total, |
| last7Days: last7Days.map(d => ({ date: d._id, count: d.count })), |
| providerStats: providerStats.map(p => ({ provider: p._id, count: p.count })), |
| avgResponseTime: Math.round(avgResponseTime[0]?.avg || 0), |
| enabled: aiConfig.enabled || false, |
| provider: aiConfig.provider || 'mgzon', |
| totalQueries: aiConfig.totalQueries || 0, |
| lastQueryAt: aiConfig.lastQueryAt || null |
| }; |
| }, |
|
|
| aiStatus: async (parent, { nickname }) => { |
| const user = await User.findOne({ |
| $or: [ |
| { 'profile.nickname': { $regex: `^${nickname}$`, $options: 'i' } }, |
| { username: { $regex: `^${nickname}$`, $options: 'i' } } |
| ] |
| }).select('profile.aiBot'); |
| |
| if (!user) throw new GraphQLError('User not found'); |
| |
| return { enabled: user.profile?.aiBot?.enabled || false }; |
| }, |
|
|
| |
| adminUsers: async (parent, { page = 1, limit = 20, role }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| const skip = (page - 1) * limit; |
| const query = {}; |
| if (role === 'admin') query.isAdmin = true; |
| else if (role === 'user') query.isAdmin = false; |
| |
| const users = await User.find(query) |
| .select('username email isAdmin createdAt profile.nickname profile.avatar profile.storeEnabled') |
| .sort({ createdAt: -1 }) |
| .skip(skip) |
| .limit(limit) |
| .lean(); |
| |
| const usersWithStats = await Promise.all(users.map(async (user) => ({ |
| ...user, |
| stats: { |
| productsCount: await Product.countDocuments({ userId: user._id }), |
| ordersCount: await Order.countDocuments({ buyerId: user._id }), |
| salesCount: await Order.countDocuments({ sellerId: user._id, status: 'completed' }) |
| } |
| }))); |
| |
| const total = await User.countDocuments(query); |
| |
| return { |
| data: usersWithStats, |
| pagination: { |
| page, |
| limit, |
| total, |
| pages: Math.ceil(total / limit), |
| hasNext: skip + limit < total, |
| hasPrev: page > 1 |
| } |
| }; |
| }, |
|
|
| adminStores: async (parent, { page = 1, limit = 20 }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| const skip = (page - 1) * limit; |
| const stores = await StoreSettings.find({}) |
| .populate('userId', 'username email profile.nickname profile.avatar profile.storeEnabled') |
| .sort({ createdAt: -1 }) |
| .skip(skip) |
| .limit(limit) |
| .lean(); |
| |
| const storesWithStats = await Promise.all(stores.map(async (store) => { |
| const productsCount = await Product.countDocuments({ userId: store.userId._id }); |
| const ordersCount = await Order.countDocuments({ sellerId: store.userId._id }); |
| const completedOrders = await Order.find({ sellerId: store.userId._id, status: 'completed' }); |
| const totalSales = completedOrders.reduce((sum, order) => sum + order.totalAmount, 0); |
| |
| return { |
| _id: store._id, |
| userId: store.userId, |
| storeName: store.storeName || store.userId.profile?.nickname || store.userId.username, |
| storeLogo: store.storeLogo, |
| enabled: store.enabled, |
| productsCount, |
| ordersCount, |
| totalSales, |
| currencySymbol: store.currencySymbol || '$', |
| createdAt: store.createdAt, |
| updatedAt: store.updatedAt |
| }; |
| })); |
| |
| const total = await StoreSettings.countDocuments(); |
| |
| return { |
| data: storesWithStats, |
| pagination: { |
| page, |
| limit, |
| total, |
| pages: Math.ceil(total / limit), |
| hasNext: skip + limit < total, |
| hasPrev: page > 1 |
| } |
| }; |
| }, |
|
|
| adminOrders: async (parent, { page = 1, limit = 20, status }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| const skip = (page - 1) * limit; |
| const query = {}; |
| if (status && status !== 'all') query.status = status; |
| |
| const [orders, total] = await Promise.all([ |
| Order.find(query) |
| .populate('buyerId', 'username email profile.nickname profile.avatar') |
| .populate('sellerId', 'username email profile.nickname profile.avatar') |
| .sort({ createdAt: -1 }) |
| .skip(skip) |
| .limit(limit) |
| .lean(), |
| Order.countDocuments(query) |
| ]); |
| |
| return { |
| data: orders, |
| pagination: { |
| page, |
| limit, |
| total, |
| pages: Math.ceil(total / limit), |
| hasNext: skip + limit < total, |
| hasPrev: page > 1 |
| } |
| }; |
| }, |
|
|
| adminProducts: async (parent, { page = 1, limit = 20, type }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| const skip = (page - 1) * limit; |
| const query = {}; |
| if (type && type !== 'all') query.type = type; |
| |
| const [products, total] = await Promise.all([ |
| Product.find(query) |
| .populate('userId', 'username email profile.nickname profile.avatar') |
| .sort({ createdAt: -1 }) |
| .skip(skip) |
| .limit(limit) |
| .lean(), |
| Product.countDocuments(query) |
| ]); |
| |
| return { |
| data: products, |
| pagination: { |
| page, |
| limit, |
| total, |
| pages: Math.ceil(total / limit), |
| hasNext: skip + limit < total, |
| hasPrev: page > 1 |
| } |
| }; |
| }, |
|
|
| adminReports: async (parent, { page = 1, limit = 20, status }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| const skip = (page - 1) * limit; |
| const query = {}; |
| if (status && status !== 'all') query.status = status; |
| |
| const [reports, total] = await Promise.all([ |
| Report.find(query) |
| .populate('reporterId', 'username email') |
| .populate('resolvedBy', 'username') |
| .sort({ createdAt: -1 }) |
| .skip(skip) |
| .limit(limit) |
| .lean(), |
| Report.countDocuments(query) |
| ]); |
| |
| return { |
| data: reports, |
| pagination: { |
| page, |
| limit, |
| total, |
| pages: Math.ceil(total / limit), |
| hasNext: skip + limit < total, |
| hasPrev: page > 1 |
| } |
| }; |
| }, |
|
|
| adminMessageReports: async (parent, { page = 1, limit = 20, status }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| const skip = (page - 1) * limit; |
| const query = {}; |
| if (status && status !== 'all') query.status = status; |
| |
| const [reports, total] = await Promise.all([ |
| MessageReport.find(query) |
| .populate('reporterId', 'username profile.nickname profile.avatar') |
| .populate('reportedUserId', 'username profile.nickname profile.avatar') |
| .sort({ reportedAt: -1 }) |
| .skip(skip) |
| .limit(limit) |
| .lean(), |
| MessageReport.countDocuments(query) |
| ]); |
| |
| return { |
| data: reports, |
| pagination: { |
| page, |
| limit, |
| total, |
| pages: Math.ceil(total / limit), |
| hasNext: skip + limit < total, |
| hasPrev: page > 1 |
| } |
| }; |
| }, |
|
|
| adminSubscriptions: async (parent, { page = 1, limit = 20, status }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| const skip = (page - 1) * limit; |
| const query = {}; |
| if (status && status !== 'all') query.status = status; |
| |
| const [subscriptions, total] = await Promise.all([ |
| UserSubscription.find(query) |
| .populate('userId', 'username email profile.nickname profile.avatar') |
| .populate('planId') |
| .sort({ createdAt: -1 }) |
| .skip(skip) |
| .limit(limit) |
| .lean(), |
| UserSubscription.countDocuments(query) |
| ]); |
| |
| return { |
| data: subscriptions, |
| pagination: { |
| page, |
| limit, |
| total, |
| pages: Math.ceil(total / limit), |
| hasNext: skip + limit < total, |
| hasPrev: page > 1 |
| } |
| }; |
| }, |
|
|
| adminSubscriptionStats: async (parent, args, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| const [total, active, pending, expired, cancelled, earnings] = await Promise.all([ |
| UserSubscription.countDocuments(), |
| UserSubscription.countDocuments({ status: 'active' }), |
| UserSubscription.countDocuments({ status: 'pending' }), |
| UserSubscription.countDocuments({ status: 'expired' }), |
| UserSubscription.countDocuments({ status: 'cancelled' }), |
| SubscriptionEarnings.aggregate([ |
| { $match: { status: 'completed' } }, |
| { $group: { _id: null, total: { $sum: '$amount' } } } |
| ]) |
| ]); |
| |
| const monthlyEarnings = await SubscriptionEarnings.aggregate([ |
| { $match: { status: 'completed' } }, |
| { $group: { |
| _id: { $dateToString: { format: '%Y-%m', date: '$createdAt' } }, |
| total: { $sum: '$amount' }, |
| count: { $sum: 1 } |
| }}, |
| { $sort: { _id: -1 } }, |
| { $limit: 12 } |
| ]); |
| |
| return { |
| total, |
| active, |
| pending, |
| expired, |
| cancelled, |
| totalRevenue: earnings[0]?.total || 0, |
| monthlyEarnings: monthlyEarnings.reverse().map(m => ({ month: m._id, total: m.total, count: m.count })) |
| }; |
| }, |
|
|
| adminLogs: async (parent, { page = 1, limit = 50, type }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| const skip = (page - 1) * limit; |
| const query = {}; |
| if (type === 'admin') query.type = { $in: ['admin_action', 'admin_report', 'admin_info'] }; |
| else if (type === 'user') query.type = { $nin: ['admin_action', 'admin_report', 'admin_info'] }; |
| |
| const [logs, total] = await Promise.all([ |
| Notification.find(query) |
| .populate('userId', 'username email') |
| .populate('actorId', 'username') |
| .sort({ createdAt: -1 }) |
| .skip(skip) |
| .limit(limit) |
| .lean(), |
| Notification.countDocuments(query) |
| ]); |
| |
| return { |
| data: logs, |
| pagination: { |
| page, |
| limit, |
| total, |
| pages: Math.ceil(total / limit), |
| hasNext: skip + limit < total, |
| hasPrev: page > 1 |
| } |
| }; |
| }, |
|
|
| adminPlans: async (parent, args, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| return await SubscriptionPlan.find().sort({ order: 1, price: 1 }); |
| }, |
|
|
| adminPaymentMethods: async (parent, args, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| return await PlatformPaymentMethod.find().sort({ order: 1 }); |
| }, |
|
|
| |
| |
| |
|
|
| templates: async (parent, { category, sort, limit = 20, offset = 0 }) => { |
| |
| if (!TemplateStore) { |
| console.warn('⚠️ TemplateStore model not loaded'); |
| return { |
| data: [], |
| pagination: { |
| page: 1, |
| limit, |
| total: 0, |
| pages: 0, |
| hasNext: false, |
| hasPrev: false |
| } |
| }; |
| } |
| |
| const query = { isActive: true }; |
| if (category && category !== 'all') query.category = category; |
| |
| let sortOption = {}; |
| switch (sort) { |
| case 'popular': sortOption = { downloadsCount: -1 }; break; |
| case 'rating': sortOption = { rating: -1 }; break; |
| case 'newest': sortOption = { createdAt: -1 }; break; |
| default: sortOption = { downloadsCount: -1 }; |
| } |
| |
| try { |
| const [templates, total] = await Promise.all([ |
| TemplateStore.find(query) |
| .sort(sortOption) |
| .limit(limit) |
| .skip(offset), |
| TemplateStore.countDocuments(query) |
| ]); |
| |
| return { |
| data: templates, |
| pagination: { |
| page: Math.floor(offset / limit) + 1, |
| limit, |
| total, |
| pages: Math.ceil(total / limit), |
| hasNext: offset + limit < total, |
| hasPrev: offset > 0 |
| } |
| }; |
| } catch (error) { |
| console.error('Templates error:', error); |
| return { |
| data: [], |
| pagination: { |
| page: 1, |
| limit, |
| total: 0, |
| pages: 0, |
| hasNext: false, |
| hasPrev: false |
| } |
| }; |
| } |
| }, |
|
|
| featuredTemplates: async (parent, { limit = 6 }) => { |
| |
| return await TemplateStore.find({ |
| isActive: true, |
| isFeatured: true |
| }) |
| .sort({ downloadsCount: -1 }) |
| .limit(limit); |
| }, |
|
|
| popularTemplates: async (parent, { limit = 12 }) => { |
| return await TemplateStore.find({ isActive: true }) |
| .sort({ downloadsCount: -1 }) |
| .limit(limit); |
| }, |
|
|
| template: async (parent, { id }) => { |
| let template; |
| if (id.match(/^[0-9a-fA-F]{24}$/)) { |
| template = await TemplateStore.findById(id); |
| } else { |
| template = await TemplateStore.findOne({ slug: id }); |
| } |
| |
| if (!template) throw new GraphQLError('Template not found'); |
| |
| |
| TemplateStore.incrementViews(template._id).catch(() => {}); |
| |
| return template; |
| }, |
|
|
| templateCategories: async () => { |
| return await TemplateStore.aggregate([ |
| { $match: { isActive: true } }, |
| { $group: { _id: '$category', count: { $sum: 1 } } }, |
| { $sort: { count: -1 } } |
| ]); |
| }, |
|
|
| myInstalledTemplates: async (parent, args, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const templates = await TemplateStore.find({ |
| 'installedBy.userId': context.user.userId, |
| isActive: true |
| }).select('name slug previewImage installedBy'); |
| |
| return templates.map(t => ({ |
| ...t.toObject(), |
| installedAt: t.installedBy.find(i => i.userId.toString() === context.user.userId)?.installedAt |
| })); |
| }, |
|
|
| |
| siteSettings: async () => { |
| let settings = await SiteSettings.findOne(); |
| if (!settings) { |
| settings = new SiteSettings(); |
| await settings.save(); |
| } |
| return settings; |
| } |
| }; |
|
|
| |
| |
| |
|
|
| const Mutation = { |
| |
| register: async (parent, { username, email, password }) => { |
| try { |
| const existingUser = await User.findOne({ email }); |
| if (existingUser) { |
| throw new GraphQLError('Email already exists'); |
| } |
| |
| const hashedPassword = await bcrypt.hash(password, 10); |
| const user = new User({ |
| username, |
| email, |
| password: hashedPassword, |
| emailVerified: false |
| }); |
| |
| await user.save(); |
| |
| const otp = Math.floor(100000 + Math.random() * 900000).toString(); |
| user.otp = otp; |
| user.otpExpires = Date.now() + 10 * 60 * 1000; |
| await user.save(); |
| |
| try { |
| await sendEmailWithBrevo( |
| email, |
| 'Verify Your Email - MGZon', |
| `<div style="font-family: Arial, sans-serif; max-width: 600px;"> |
| <h2>Welcome to MGZon! 🎉</h2> |
| <p>Your verification code is:</p> |
| <div style="background: #f3f4f6; padding: 20px; text-align: center; font-size: 32px; letter-spacing: 8px; font-weight: bold; border-radius: 10px;"> |
| ${otp} |
| </div> |
| <p>Valid for 10 minutes.</p> |
| </div>`, |
| `Your verification code is: ${otp}` |
| ); |
| } catch (e) { |
| console.log('Email error:', e.message); |
| } |
| |
| return { |
| success: true, |
| token: null, |
| refreshToken: null, |
| user: null, |
| requiresVerification: true, |
| email: email, |
| message: 'Registration successful! Please verify your email.' |
| }; |
| |
| } catch (error) { |
| console.error('Register error:', error); |
| if (error instanceof GraphQLError) { |
| throw error; |
| } |
| throw new GraphQLError('Registration failed: ' + error.message); |
| } |
| }, |
|
|
| login: async (parent, { email, password }) => { |
| try { |
| const user = await User.findOne({ email }); |
| |
| if (!user) { |
| throw new GraphQLError('Invalid credentials'); |
| } |
| |
| const isValidPassword = await bcrypt.compare(password, user.password); |
| if (!isValidPassword) { |
| throw new GraphQLError('Invalid credentials'); |
| } |
| |
| |
| if (user.isAdmin) { |
| const token = jwt.sign( |
| { userId: user._id, isAdmin: user.isAdmin }, |
| process.env.JWT_SECRET, |
| { expiresIn: '30d' } |
| ); |
| const refreshToken = jwt.sign( |
| { userId: user._id }, |
| process.env.JWT_SECRET, |
| { expiresIn: '90d' } |
| ); |
| |
| user.refreshTokens = user.refreshTokens || []; |
| user.refreshTokens.push({ token: refreshToken, createdAt: new Date() }); |
| await user.save(); |
| |
| return { |
| success: true, |
| token: token, |
| refreshToken: refreshToken, |
| user: user, |
| requiresVerification: false, |
| email: user.email, |
| message: 'Login successful' |
| }; |
| } |
| |
| |
| if (!user.emailVerified) { |
| const otp = Math.floor(100000 + Math.random() * 900000).toString(); |
| user.otp = otp; |
| user.otpExpires = Date.now() + 10 * 60 * 1000; |
| await user.save(); |
| |
| try { |
| await sendEmailWithBrevo( |
| email, |
| 'Verify Your Email - MGZon', |
| `<div><h2>Verification Code</h2> |
| <div style="background: #f3f4f6; padding: 20px; font-size: 32px; letter-spacing: 8px;">${otp}</div> |
| <p>Valid for 10 minutes.</p></div>`, |
| `Your verification code is: ${otp}` |
| ); |
| } catch (e) { |
| console.log('Email error:', e.message); |
| } |
| |
| return { |
| success: false, |
| token: null, |
| refreshToken: null, |
| user: null, |
| requiresVerification: true, |
| email: email, |
| message: 'Email not verified. OTP sent to your email.' |
| }; |
| } |
| |
| |
| const token = jwt.sign( |
| { userId: user._id, isAdmin: user.isAdmin }, |
| process.env.JWT_SECRET, |
| { expiresIn: '30d' } |
| ); |
| const refreshToken = jwt.sign( |
| { userId: user._id }, |
| process.env.JWT_SECRET, |
| { expiresIn: '90d' } |
| ); |
| |
| user.refreshTokens = user.refreshTokens || []; |
| user.refreshTokens.push({ token: refreshToken, createdAt: new Date() }); |
| await user.save(); |
| |
| return { |
| success: true, |
| token: token, |
| refreshToken: refreshToken, |
| user: user, |
| requiresVerification: false, |
| email: user.email, |
| message: 'Login successful' |
| }; |
| |
| } catch (error) { |
| console.error('Login error:', error); |
| if (error instanceof GraphQLError) { |
| throw error; |
| } |
| throw new GraphQLError('Login failed: ' + error.message); |
| } |
| }, |
|
|
| verifyEmail: async (parent, { email, otp }) => { |
| try { |
| const user = await User.findOne({ email, otp, otpExpires: { $gt: Date.now() } }); |
| if (!user) { |
| throw new GraphQLError('Invalid or expired verification code'); |
| } |
| |
| user.emailVerified = true; |
| user.otp = null; |
| user.otpExpires = null; |
| await user.save(); |
| |
| const token = jwt.sign( |
| { userId: user._id, isAdmin: user.isAdmin }, |
| process.env.JWT_SECRET, |
| { expiresIn: '30d' } |
| ); |
| const refreshToken = jwt.sign( |
| { userId: user._id }, |
| process.env.JWT_SECRET, |
| { expiresIn: '90d' } |
| ); |
| |
| user.refreshTokens = user.refreshTokens || []; |
| user.refreshTokens.push({ token: refreshToken, createdAt: new Date() }); |
| await user.save(); |
| |
| return { |
| success: true, |
| token: token, |
| refreshToken: refreshToken, |
| user: user, |
| requiresVerification: false, |
| email: user.email, |
| message: 'Email verified successfully!' |
| }; |
| |
| } catch (error) { |
| console.error('Verify email error:', error); |
| if (error instanceof GraphQLError) { |
| throw error; |
| } |
| throw new GraphQLError('Verification failed: ' + error.message); |
| } |
| }, |
|
|
| resendVerification: async (parent, { email }) => { |
| try { |
| const user = await User.findOne({ email }); |
| if (!user) throw new GraphQLError('User not found'); |
| if (user.emailVerified) throw new GraphQLError('Email already verified'); |
| |
| const otp = Math.floor(100000 + Math.random() * 900000).toString(); |
| user.otp = otp; |
| user.otpExpires = Date.now() + 10 * 60 * 1000; |
| await user.save(); |
| |
| try { |
| await sendEmailWithBrevo( |
| email, |
| 'Your Verification Code - MGZon', |
| `<div><h2>Verification Code</h2> |
| <div style="background: #f3f4f6; padding: 20px; font-size: 32px;">${otp}</div> |
| <p>Valid for 10 minutes.</p></div>`, |
| `Your verification code is: ${otp}` |
| ); |
| } catch (e) {} |
| |
| return true; |
| |
| } catch (error) { |
| console.error('Resend verification error:', error); |
| if (error instanceof GraphQLError) { |
| throw error; |
| } |
| throw new GraphQLError('Failed to resend code: ' + error.message); |
| } |
| }, |
|
|
| forgotPassword: async (parent, { email }) => { |
| try { |
| const user = await User.findOne({ email }); |
| if (!user) throw new GraphQLError('User not found'); |
| |
| const otp = Math.floor(100000 + Math.random() * 900000).toString(); |
| user.otp = otp; |
| user.otpExpires = Date.now() + 10 * 60 * 1000; |
| await user.save(); |
| |
| try { |
| await sendEmailWithBrevo( |
| email, |
| 'Password Reset Code - MGZon', |
| `<div><h2>Password Reset</h2> |
| <div style="background: #f3f4f6; padding: 20px; font-size: 32px;">${otp}</div> |
| <p>Valid for 10 minutes.</p></div>`, |
| `Your password reset code is: ${otp}` |
| ); |
| } catch (e) {} |
| |
| return true; |
| |
| } catch (error) { |
| console.error('Forgot password error:', error); |
| if (error instanceof GraphQLError) { |
| throw error; |
| } |
| throw new GraphQLError('Failed to send reset code: ' + error.message); |
| } |
| }, |
|
|
| resetPassword: async (parent, { email, otp, newPassword }) => { |
| try { |
| const user = await User.findOne({ email, otp, otpExpires: { $gt: Date.now() } }); |
| if (!user) throw new GraphQLError('Invalid or expired OTP'); |
| |
| user.password = await bcrypt.hash(newPassword, 10); |
| user.otp = null; |
| user.otpExpires = null; |
| await user.save(); |
| |
| return true; |
| |
| } catch (error) { |
| console.error('Reset password error:', error); |
| if (error instanceof GraphQLError) { |
| throw error; |
| } |
| throw new GraphQLError('Failed to reset password: ' + error.message); |
| } |
| }, |
|
|
| refreshToken: async (parent, { refreshToken }) => { |
| try { |
| const decoded = jwt.verify(refreshToken, process.env.JWT_SECRET); |
| const user = await User.findOne({ _id: decoded.userId, 'refreshTokens.token': refreshToken }); |
| if (!user) throw new GraphQLError('Invalid refresh token'); |
| |
| const token = jwt.sign( |
| { userId: user._id, isAdmin: user.isAdmin }, |
| process.env.JWT_SECRET, |
| { expiresIn: '30d' } |
| ); |
| const newRefreshToken = jwt.sign( |
| { userId: user._id }, |
| process.env.JWT_SECRET, |
| { expiresIn: '90d' } |
| ); |
| |
| user.refreshTokens = user.refreshTokens.filter(t => t.token !== refreshToken); |
| user.refreshTokens.push({ token: newRefreshToken }); |
| await user.save(); |
| |
| return { |
| success: true, |
| token: token, |
| refreshToken: newRefreshToken, |
| user: user |
| }; |
| } catch (error) { |
| console.error('Refresh token error:', error); |
| throw new GraphQLError('Invalid or expired refresh token'); |
| } |
| }, |
|
|
| logout: async (parent, { refreshToken }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const user = await User.findById(context.user.userId); |
| if (!user) throw new GraphQLError('User not found'); |
| |
| user.refreshTokens = user.refreshTokens.filter(t => t.token !== refreshToken); |
| await user.save(); |
| |
| return true; |
| }, |
|
|
| magicLink: async (parent, { email }) => { |
| try { |
| let user = await User.findOne({ email }); |
| if (!user) { |
| user = new User({ |
| email, |
| username: email.split('@')[0], |
| emailVerified: false, |
| profile: { nickname: email.split('@')[0] } |
| }); |
| await user.save(); |
| } |
| |
| const magicToken = jwt.sign( |
| { userId: user._id, email, purpose: 'magic-link' }, |
| process.env.JWT_SECRET, |
| { expiresIn: '15m' } |
| ); |
| |
| const frontendUrl = process.env.FRONTEND_URL_XCV || 'https://xccv.vercel.app'; |
| const magicLink = `${frontendUrl}/auth/callback?token=${magicToken}&provider=magic-link`; |
| |
| try { |
| await sendEmailWithBrevo( |
| email, |
| '✨ Sign in to XCV with Magic Link', |
| `<div><h2>Welcome!</h2> |
| <a href="${magicLink}" style="display:inline-block;padding:12px 24px;background:#667eea;color:white;border-radius:8px;">Sign In</a> |
| <p>Valid for 15 minutes.</p></div>`, |
| `Sign in with this link: ${magicLink}` |
| ); |
| } catch (e) {} |
| |
| return true; |
| |
| } catch (error) { |
| console.error('Magic link error:', error); |
| throw new GraphQLError('Failed to send magic link: ' + error.message); |
| } |
| }, |
|
|
| verifyMagicLink: async (parent, { token }) => { |
| try { |
| const decoded = jwt.verify(token, process.env.JWT_SECRET); |
| if (decoded.purpose !== 'magic-link') { |
| throw new GraphQLError('Invalid token purpose'); |
| } |
| |
| const user = await User.findById(decoded.userId); |
| if (!user) throw new GraphQLError('User not found'); |
| |
| user.emailVerified = true; |
| |
| const accessToken = jwt.sign( |
| { userId: user._id, email: user.email }, |
| process.env.JWT_SECRET, |
| { expiresIn: '30d' } |
| ); |
| const refreshToken = jwt.sign( |
| { userId: user._id }, |
| process.env.JWT_SECRET, |
| { expiresIn: '90d' } |
| ); |
| |
| user.refreshTokens = user.refreshTokens || []; |
| user.refreshTokens.push({ token: refreshToken }); |
| await user.save(); |
| |
| return { |
| success: true, |
| token: accessToken, |
| refreshToken: refreshToken, |
| user: user |
| }; |
| } catch (error) { |
| console.error('Verify magic link error:', error); |
| if (error.name === 'TokenExpiredError') { |
| throw new GraphQLError('Magic link has expired. Please request a new one.'); |
| } |
| throw new GraphQLError('Invalid magic link: ' + error.message); |
| } |
| }, |
|
|
| |
| addComment: async (parent, { projectId, projectOwnerId, rating, text }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| if (!mongoose.Types.ObjectId.isValid(projectId)) { |
| throw new GraphQLError('Invalid project ID'); |
| } |
| if (!mongoose.Types.ObjectId.isValid(projectOwnerId)) { |
| throw new GraphQLError('Invalid project owner ID'); |
| } |
| |
| const project = await Project.findById(projectId); |
| if (!project) throw new GraphQLError('Project not found'); |
| |
| const comment = new Comment({ |
| projectId: new mongoose.Types.ObjectId(projectId), |
| projectOwnerId: new mongoose.Types.ObjectId(projectOwnerId), |
| userId: context.user.userId, |
| rating: rating, |
| text: text, |
| timestamp: new Date(), |
| likes: [], |
| reports: [], |
| reportCount: 0, |
| hidden: false, |
| visibility: 'public' |
| }); |
| |
| await comment.save(); |
| |
| |
| if (projectOwnerId !== context.user.userId) { |
| const user = await User.findById(context.user.userId); |
| const notification = new Notification({ |
| userId: projectOwnerId, |
| type: 'comment', |
| actorId: context.user.userId, |
| actorName: user.profile?.nickname || user.username, |
| actorAvatar: user.profile?.avatar, |
| targetId: projectId, |
| targetType: 'project', |
| content: `commented on your project: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}" (${rating}⭐)`, |
| read: false |
| }); |
| await notification.save(); |
| } |
| |
| return comment; |
| }, |
|
|
| addCommentReply: async (parent, { commentId, text, rating }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const parentComment = await Comment.findById(commentId).populate('userId', 'email username profile.nickname'); |
| if (!parentComment) throw new GraphQLError('Comment not found'); |
| if (parentComment.hidden) throw new GraphQLError('Cannot reply to a hidden comment'); |
| |
| let finalRating = rating; |
| if (!finalRating || finalRating < 1) { |
| finalRating = parentComment.rating >= 1 ? parentComment.rating : 1; |
| } |
| |
| const reply = new Comment({ |
| projectId: parentComment.projectId, |
| projectOwnerId: parentComment.projectOwnerId, |
| userId: context.user.userId, |
| rating: finalRating, |
| text: text, |
| parentCommentId: commentId, |
| timestamp: new Date(), |
| likes: [], |
| reports: [], |
| reportCount: 0, |
| hidden: false, |
| visibility: parentComment.visibility |
| }); |
| |
| await reply.save(); |
| |
| parentComment.replies.push(reply._id); |
| await parentComment.save(); |
| |
| |
| if (parentComment.userId._id.toString() !== context.user.userId) { |
| const replyAuthor = await User.findById(context.user.userId); |
| const notification = new Notification({ |
| userId: parentComment.userId._id, |
| type: 'reply', |
| actorId: context.user.userId, |
| actorName: replyAuthor?.profile?.nickname || replyAuthor?.username || 'Someone', |
| targetId: parentComment.projectId, |
| targetType: 'comment', |
| content: `replied to your comment: "${text.substring(0, 60)}${text.length > 60 ? '...' : ''}"`, |
| read: false |
| }); |
| await notification.save(); |
| } |
| |
| return reply; |
| }, |
|
|
| updateComment: async (parent, { id, text }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const comment = await Comment.findById(id); |
| if (!comment) throw new GraphQLError('Comment not found'); |
| |
| if (comment.userId.toString() !== context.user.userId && !context.isAdmin) { |
| throw new GraphQLError('Unauthorized to edit this comment'); |
| } |
| |
| comment.text = text; |
| comment.isEdited = true; |
| comment.editedAt = new Date(); |
| await comment.save(); |
| |
| return comment; |
| }, |
|
|
| deleteComment: async (parent, { id }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const comment = await Comment.findById(id); |
| if (!comment) throw new GraphQLError('Comment not found'); |
| |
| let isProjectOwner = false; |
| if (comment.projectOwnerId) { |
| isProjectOwner = comment.projectOwnerId.toString() === context.user.userId; |
| } else { |
| const project = await Project.findById(comment.projectId); |
| isProjectOwner = project && project.userId.toString() === context.user.userId; |
| } |
| |
| const isCommentOwner = comment.userId.toString() === context.user.userId; |
| |
| if (!isCommentOwner && !context.isAdmin && !isProjectOwner) { |
| throw new GraphQLError('Unauthorized to delete this comment'); |
| } |
| |
| |
| if (comment.replies && comment.replies.length > 0) { |
| await Comment.deleteMany({ _id: { $in: comment.replies } }); |
| } |
| |
| if (comment.parentCommentId) { |
| await Comment.updateOne( |
| { _id: comment.parentCommentId }, |
| { $pull: { replies: comment._id } } |
| ); |
| } |
| |
| await Comment.findByIdAndDelete(id); |
| return true; |
| }, |
|
|
| likeComment: async (parent, { id }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const comment = await Comment.findById(id); |
| if (!comment) throw new GraphQLError('Comment not found'); |
| if (comment.hidden) throw new GraphQLError('Cannot like a hidden comment'); |
| |
| const userId = context.user.userId; |
| const hasLiked = comment.likes && comment.likes.includes(userId); |
| |
| if (hasLiked) { |
| comment.likes = comment.likes.filter(id => id.toString() !== userId); |
| } else { |
| if (!comment.likes) comment.likes = []; |
| comment.likes.push(userId); |
| } |
| |
| await comment.save(); |
| |
| return { |
| success: true, |
| likesCount: comment.likes.length, |
| isLiked: !hasLiked |
| }; |
| }, |
|
|
| reportComment: async (parent, { commentId, reason }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const comment = await Comment.findById(commentId).populate('userId', 'username email profile.nickname'); |
| if (!comment) throw new GraphQLError('Comment not found'); |
| |
| if (comment.userId._id.toString() === context.user.userId) { |
| throw new GraphQLError('You cannot report your own comment'); |
| } |
| |
| if (comment.reports?.some(r => r.userId.toString() === context.user.userId)) { |
| throw new GraphQLError('You already reported this comment'); |
| } |
| |
| comment.reports = comment.reports || []; |
| comment.reports.push({ |
| userId: context.user.userId, |
| reason: reason || 'No reason provided', |
| reportedAt: new Date(), |
| reporterUsername: context.user.username, |
| resolved: false |
| }); |
| comment.reportCount = (comment.reportCount || 0) + 1; |
| |
| let hiddenMessage = ''; |
| if (comment.reportCount >= 3) { |
| comment.hidden = true; |
| hiddenMessage = ' Comment has been hidden.'; |
| } |
| |
| await comment.save(); |
| |
| |
| const adminUsers = await User.find({ isAdmin: true }); |
| for (const admin of adminUsers) { |
| const notification = new Notification({ |
| userId: admin._id, |
| type: 'admin_report', |
| actorId: context.user.userId, |
| actorName: context.user.username, |
| targetId: comment._id, |
| targetType: 'comment', |
| content: `🚨 New report on comment by ${comment.userId.username || comment.userId.profile?.nickname}: "${comment.text.substring(0, 80)}${comment.text.length > 80 ? '...' : ''}" (${comment.reportCount}/3)`, |
| link: `/admin/comments?highlight=${comment._id}`, |
| read: false |
| }); |
| await notification.save(); |
| } |
| |
| return { |
| success: true, |
| reportCount: comment.reportCount, |
| hidden: comment.hidden || false, |
| message: comment.reportCount >= 3 ? 'Comment has been hidden due to multiple reports' : 'Report submitted successfully' |
| }; |
| }, |
|
|
| resolveCommentReport: async (parent, { commentId, action, adminNote }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| const comment = await Comment.findById(commentId).populate('userId', 'username email profile.nickname'); |
| if (!comment) throw new GraphQLError('Comment not found'); |
| |
| if (comment.reports && comment.reports.length > 0) { |
| comment.reports.forEach(r => { |
| r.resolved = true; |
| r.resolvedAt = new Date(); |
| r.resolvedBy = context.user.userId; |
| r.adminNote = adminNote || ''; |
| }); |
| } |
| |
| let actionMessage = ''; |
| |
| if (action === 'hide') { |
| comment.hidden = true; |
| actionMessage = 'Comment has been hidden'; |
| } else if (action === 'delete') { |
| await Comment.findByIdAndDelete(commentId); |
| return comment; |
| } else { |
| comment.hidden = false; |
| actionMessage = 'Comment remains visible'; |
| } |
| |
| await comment.save(); |
| |
| |
| if (comment.userId) { |
| const notification = new Notification({ |
| userId: comment.userId._id, |
| type: 'report_resolved', |
| actorId: context.user.userId, |
| actorName: context.user.username, |
| targetId: comment._id, |
| targetType: 'comment', |
| content: `Report on your comment resolved: ${actionMessage}. ${adminNote ? `Note: ${adminNote}` : ''}`, |
| read: false |
| }); |
| await notification.save(); |
| } |
| |
| return comment; |
| }, |
|
|
| updateCommentPrivacy: async (parent, { id, visibility }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const comment = await Comment.findById(id); |
| if (!comment) throw new GraphQLError('Comment not found'); |
| |
| if (comment.userId.toString() !== context.user.userId && !context.isAdmin) { |
| throw new GraphQLError('Unauthorized'); |
| } |
| |
| comment.visibility = visibility; |
| await comment.save(); |
| |
| return comment; |
| }, |
|
|
| |
| createPost: async (parent, { input, files }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const post = new Post({ |
| userId: context.user.userId, |
| content: input.content, |
| visibility: input.visibility || 'public', |
| tags: input.tags || [], |
| mentions: input.mentions || [] |
| }); |
| |
| await post.save(); |
| return post; |
| }, |
|
|
| updatePost: async (parent, { id, input }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const post = await Post.findById(id); |
| if (!post) throw new GraphQLError('Post not found'); |
| if (post.userId.toString() !== context.user.userId && !context.isAdmin) { |
| throw new GraphQLError('Unauthorized to edit this post'); |
| } |
| |
| if (input.content) post.content = input.content; |
| if (input.visibility) post.visibility = input.visibility; |
| if (input.tags) post.tags = input.tags; |
| |
| post.isEdited = true; |
| post.editedAt = new Date(); |
| await post.save(); |
| |
| return post; |
| }, |
|
|
| deletePost: async (parent, { id }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const post = await Post.findById(id); |
| if (!post) throw new GraphQLError('Post not found'); |
| if (post.userId.toString() !== context.user.userId && !context.isAdmin) { |
| throw new GraphQLError('Unauthorized to delete this post'); |
| } |
| |
| await post.deleteOne(); |
| return true; |
| }, |
|
|
| likePost: async (parent, { id }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const post = await Post.findById(id); |
| if (!post) throw new GraphQLError('Post not found'); |
| |
| const hasLiked = post.likes.some(l => l.userId.toString() === context.user.userId); |
| |
| if (hasLiked) { |
| post.likes = post.likes.filter(l => l.userId.toString() !== context.user.userId); |
| } else { |
| post.likes.push({ userId: context.user.userId, likedAt: new Date() }); |
| |
| if (post.userId.toString() !== context.user.userId) { |
| const notification = new Notification({ |
| userId: post.userId, |
| type: 'like', |
| actorId: context.user.userId, |
| targetId: post._id, |
| targetType: 'post', |
| content: `liked your post`, |
| read: false |
| }); |
| await notification.save(); |
| } |
| } |
| |
| await post.save(); |
| |
| return { |
| success: true, |
| likesCount: post.likes.length, |
| isLiked: !hasLiked |
| }; |
| }, |
|
|
| savePost: async (parent, { id }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const post = await Post.findById(id); |
| if (!post) throw new GraphQLError('Post not found'); |
| |
| const hasSaved = post.saves.some(s => s.userId.toString() === context.user.userId); |
| |
| if (hasSaved) { |
| post.saves = post.saves.filter(s => s.userId.toString() !== context.user.userId); |
| } else { |
| post.saves.push({ userId: context.user.userId, savedAt: new Date() }); |
| } |
| |
| await post.save(); |
| |
| return { |
| success: true, |
| count: post.saves.length, |
| isSaved: !hasSaved |
| }; |
| }, |
|
|
| pinPost: async (parent, { id }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const post = await Post.findOne({ _id: id, userId: context.user.userId }); |
| if (!post) throw new GraphQLError('Post not found'); |
| |
| if (!post.pinned) { |
| await Post.updateMany( |
| { userId: context.user.userId, pinned: true }, |
| { $set: { pinned: false, pinnedAt: null } } |
| ); |
| } |
| |
| post.pinned = !post.pinned; |
| post.pinnedAt = post.pinned ? new Date() : null; |
| await post.save(); |
| |
| return post; |
| }, |
|
|
| sharePost: async (parent, { id }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const post = await Post.findById(id); |
| if (!post) throw new GraphQLError('Post not found'); |
| |
| const hasShared = post.shares.some(s => s.userId.toString() === context.user.userId); |
| |
| if (!hasShared) { |
| post.shares.push({ userId: context.user.userId, sharedAt: new Date() }); |
| |
| if (post.userId.toString() !== context.user.userId) { |
| const notification = new Notification({ |
| userId: post.userId, |
| type: 'share', |
| actorId: context.user.userId, |
| targetId: post._id, |
| targetType: 'post', |
| content: `shared your post`, |
| read: false |
| }); |
| await notification.save(); |
| } |
| } |
| |
| await post.save(); |
| |
| return { |
| success: true, |
| sharesCount: post.shares.length |
| }; |
| }, |
|
|
| sharePostToProfile: async (parent, { originalPostId, content }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const originalPost = await Post.findById(originalPostId) |
| .populate('userId', 'username profile.nickname profile.avatar'); |
| |
| if (!originalPost) throw new GraphQLError('Original post not found'); |
| |
| const sharedContent = content || `Shared a post by ${originalPost.userId.profile?.nickname || originalPost.userId.username}: ${originalPost.content.substring(0, 200)}${originalPost.content.length > 200 ? '...' : ''}`; |
| |
| const sharedPost = new Post({ |
| userId: context.user.userId, |
| content: sharedContent, |
| visibility: 'public', |
| sharedFrom: { |
| originalPostId: originalPost._id, |
| originalAuthorId: originalPost.userId._id, |
| originalAuthorName: originalPost.userId.profile?.nickname || originalPost.userId.username, |
| sharedAt: new Date() |
| }, |
| createdAt: new Date() |
| }); |
| |
| await sharedPost.save(); |
| |
| if (originalPost.userId._id.toString() !== context.user.userId) { |
| const notification = new Notification({ |
| userId: originalPost.userId._id, |
| type: 'share', |
| actorId: context.user.userId, |
| targetId: originalPost._id, |
| targetType: 'post', |
| content: `shared your post on their profile`, |
| read: false |
| }); |
| await notification.save(); |
| } |
| |
| return sharedPost; |
| }, |
|
|
| addPostComment: async (parent, { postId, text }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const post = await Post.findById(postId); |
| if (!post) throw new GraphQLError('Post not found'); |
| |
| const comment = { |
| userId: context.user.userId, |
| text, |
| createdAt: new Date(), |
| likes: [] |
| }; |
| |
| post.comments.push(comment); |
| await post.save(); |
| |
| if (post.userId.toString() !== context.user.userId) { |
| const notification = new Notification({ |
| userId: post.userId, |
| type: 'comment', |
| actorId: context.user.userId, |
| targetId: post._id, |
| targetType: 'post', |
| content: `commented on your post: "${text.substring(0, 50)}"`, |
| read: false |
| }); |
| await notification.save(); |
| } |
| |
| return comment; |
| }, |
|
|
| updatePostComment: async (parent, { postId, commentId, text }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const post = await Post.findById(postId); |
| if (!post) throw new GraphQLError('Post not found'); |
| |
| const comment = post.comments.id(commentId); |
| if (!comment) throw new GraphQLError('Comment not found'); |
| if (comment.userId.toString() !== context.user.userId && !context.isAdmin) { |
| throw new GraphQLError('Unauthorized to edit this comment'); |
| } |
| |
| comment.text = text; |
| comment.edited = true; |
| comment.editedAt = new Date(); |
| await post.save(); |
| |
| return comment; |
| }, |
|
|
| deletePostComment: async (parent, { postId, commentId }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const post = await Post.findById(postId); |
| if (!post) throw new GraphQLError('Post not found'); |
| |
| const comment = post.comments.id(commentId); |
| if (!comment) throw new GraphQLError('Comment not found'); |
| |
| const isCommentOwner = comment.userId.toString() === context.user.userId; |
| const isPostOwner = post.userId.toString() === context.user.userId; |
| |
| if (!isCommentOwner && !isPostOwner && !context.isAdmin) { |
| throw new GraphQLError('Unauthorized to delete this comment'); |
| } |
| |
| post.comments.pull({ _id: commentId }); |
| await post.save(); |
| return true; |
| }, |
|
|
| likePostComment: async (parent, { commentId }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const post = await Post.findOne({ 'comments._id': commentId }); |
| if (!post) throw new GraphQLError('Comment not found'); |
| |
| const comment = post.comments.id(commentId); |
| if (!comment) throw new GraphQLError('Comment not found'); |
| |
| const hasLiked = comment.likes && comment.likes.includes(context.user.userId); |
| |
| if (hasLiked) { |
| comment.likes = comment.likes.filter(id => id.toString() !== context.user.userId); |
| } else { |
| if (!comment.likes) comment.likes = []; |
| comment.likes.push(context.user.userId); |
| } |
| |
| await post.save(); |
| |
| return { |
| success: true, |
| likesCount: comment.likes.length, |
| isLiked: !hasLiked |
| }; |
| }, |
|
|
| reportPostComment: async (parent, { commentId, reason }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const post = await Post.findOne({ 'comments._id': commentId }); |
| if (!post) throw new GraphQLError('Comment not found'); |
| |
| const comment = post.comments.id(commentId); |
| if (!comment) throw new GraphQLError('Comment not found'); |
| |
| if (comment.userId.toString() === context.user.userId) { |
| throw new GraphQLError('You cannot report your own comment'); |
| } |
| |
| if (!comment.reports) comment.reports = []; |
| if (comment.reports.some(r => r.userId.toString() === context.user.userId)) { |
| throw new GraphQLError('You already reported this comment'); |
| } |
| |
| comment.reports.push({ |
| userId: context.user.userId, |
| reason: reason || 'No reason provided', |
| reportedAt: new Date() |
| }); |
| comment.reportCount = (comment.reportCount || 0) + 1; |
| |
| if (comment.reportCount >= 3) { |
| comment.hidden = true; |
| } |
| |
| await post.save(); |
| return true; |
| }, |
|
|
| |
| createStory: async (parent, { input, file }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| |
| const defaultTextStyle = { |
| fontSize: 32, |
| fontFamily: 'Inter', |
| fontWeight: '700', |
| textAlign: 'center', |
| position: { x: 50, y: 50 }, |
| rotation: 0, |
| opacity: 1, |
| textShadow: '0 2px 10px rgba(0,0,0,0.5)', |
| background: 'transparent', |
| padding: '0', |
| borderRadius: '0', |
| maxWidth: 90 |
| }; |
| |
| const story = new Story({ |
| userId: context.user.userId, |
| text: input.text || '', |
| backgroundColor: input.backgroundColor || '#000000', |
| textColor: input.textColor || '#ffffff', |
| textStyle: input.textStyle ? { ...defaultTextStyle, ...input.textStyle } : defaultTextStyle, |
| visibility: input.visibility || 'followers' |
| }); |
| |
| await story.save(); |
| return story; |
| }, |
|
|
| deleteStory: async (parent, { id }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const story = await Story.findById(id); |
| if (!story) throw new GraphQLError('Story not found'); |
| if (story.userId.toString() !== context.user.userId && !context.isAdmin) { |
| throw new GraphQLError('Unauthorized to delete this story'); |
| } |
| |
| await story.deleteOne(); |
| return true; |
| }, |
|
|
| viewStory: async (parent, { id }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const story = await Story.findById(id); |
| if (!story) throw new GraphQLError('Story not found'); |
| if (story.isExpired()) throw new GraphQLError('Story has expired'); |
| |
| if (story.userId.toString() === context.user.userId) { |
| return story; |
| } |
| |
| const hasViewed = story.views.some(v => v.userId.toString() === context.user.userId); |
| |
| if (!hasViewed) { |
| story.views.push({ userId: context.user.userId, viewedAt: new Date() }); |
| await story.save(); |
| } |
| |
| return story; |
| }, |
|
|
| reactToStory: async (parent, { id, type }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const story = await Story.findById(id); |
| if (!story) throw new GraphQLError('Story not found'); |
| if (story.isExpired()) throw new GraphQLError('Story has expired'); |
| |
| const existingReaction = story.reactions.find( |
| r => r.userId.toString() === context.user.userId |
| ); |
| |
| if (existingReaction) { |
| existingReaction.type = type; |
| existingReaction.createdAt = new Date(); |
| } else { |
| story.reactions.push({ |
| userId: context.user.userId, |
| type: type, |
| createdAt: new Date() |
| }); |
| } |
| |
| await story.save(); |
| return story; |
| }, |
|
|
| deleteExpiredStories: async (parent, args, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| const result = await Story.deleteMany({ |
| expiresAt: { $lt: new Date() } |
| }); |
| |
| return { deletedCount: result.deletedCount }; |
| }, |
|
|
| |
| createJob: async (parent, { input }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const user = await User.findById(context.user.userId); |
| const job = new Job({ |
| ...input, |
| employerId: context.user.userId, |
| companyName: input.companyName || user.profile?.portfolioName || user.username, |
| companyLogo: input.companyLogo || user.profile?.avatar |
| }); |
| |
| await job.save(); |
| return job; |
| }, |
|
|
| updateJob: async (parent, { id, input }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const job = await Job.findById(id); |
| if (!job) throw new GraphQLError('Job not found'); |
| if (job.employerId.toString() !== context.user.userId && !context.isAdmin) { |
| throw new GraphQLError('Unauthorized to update this job'); |
| } |
| |
| const allowedFields = [ |
| 'title', 'description', 'requirements', 'responsibilities', |
| 'category', 'jobType', 'experienceLevel', 'location', 'isRemote', |
| 'salaryMin', 'salaryMax', 'salaryCurrency', 'isSalaryNegotiable', |
| 'requiredSkills', 'educationLevel', 'vacancies', 'deadline', 'status' |
| ]; |
| |
| for (const field of allowedFields) { |
| if (input[field] !== undefined) { |
| job[field] = input[field]; |
| } |
| } |
| |
| await job.save(); |
| return job; |
| }, |
|
|
| deleteJob: async (parent, { id }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const job = await Job.findById(id); |
| if (!job) throw new GraphQLError('Job not found'); |
| if (job.employerId.toString() !== context.user.userId && !context.isAdmin) { |
| throw new GraphQLError('Unauthorized to delete this job'); |
| } |
| |
| await JobApplication.deleteMany({ jobId: job._id }); |
| await JobSave.deleteMany({ jobId: job._id }); |
| await job.deleteOne(); |
| return true; |
| }, |
|
|
| duplicateJob: async (parent, { id }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const originalJob = await Job.findById(id); |
| if (!originalJob) throw new GraphQLError('Job not found'); |
| if (originalJob.employerId.toString() !== context.user.userId && !context.isAdmin) { |
| throw new GraphQLError('Unauthorized to duplicate this job'); |
| } |
| |
| const newJob = new Job({ |
| ...originalJob.toObject(), |
| _id: undefined, |
| title: `${originalJob.title} (Copy)`, |
| postedAt: new Date(), |
| views: 0, |
| applicationsCount: 0, |
| status: 'draft' |
| }); |
| |
| await newJob.save(); |
| return newJob; |
| }, |
|
|
| applyToJob: async (parent, { jobId, input, resume }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const job = await Job.findById(jobId); |
| if (!job) throw new GraphQLError('Job not found'); |
| if (job.status !== 'active' || job.deadline < new Date()) { |
| throw new GraphQLError('This job is no longer accepting applications'); |
| } |
| |
| const existingApplication = await JobApplication.findOne({ |
| jobId: job._id, |
| applicantId: context.user.userId |
| }); |
| if (existingApplication) { |
| throw new GraphQLError('You have already applied for this job'); |
| } |
| |
| const application = new JobApplication({ |
| jobId: job._id, |
| applicantId: context.user.userId, |
| fullName: input.fullName, |
| email: input.email, |
| phone: input.phone, |
| coverLetter: input.coverLetter, |
| portfolio: input.portfolio, |
| linkedin: input.linkedin, |
| github: input.github, |
| answers: input.answers || [] |
| }); |
| |
| await application.save(); |
| |
| await Job.findByIdAndUpdate(job._id, { $inc: { applicationsCount: 1 } }); |
| |
| const applicant = await User.findById(context.user.userId); |
| const notification = new Notification({ |
| userId: job.employerId, |
| type: 'application', |
| content: `New application from ${applicant.profile?.nickname || applicant.username} for "${job.title}"`, |
| targetId: application._id, |
| targetType: 'application', |
| read: false |
| }); |
| await notification.save(); |
| |
| return application; |
| }, |
|
|
| withdrawApplication: async (parent, { applicationId }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const application = await JobApplication.findById(applicationId); |
| if (!application) throw new GraphQLError('Application not found'); |
| if (application.applicantId.toString() !== context.user.userId) { |
| throw new GraphQLError('Unauthorized to withdraw this application'); |
| } |
| if (application.status !== 'pending') { |
| throw new GraphQLError(`Cannot withdraw application with status: ${application.status}`); |
| } |
| |
| application.status = 'withdrawn'; |
| application.updatedAt = new Date(); |
| await application.save(); |
| |
| return application; |
| }, |
|
|
| updateApplicationStatus: async (parent, { applicationId, status, employerNotes }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const application = await JobApplication.findById(applicationId).populate('jobId'); |
| if (!application) throw new GraphQLError('Application not found'); |
| if (application.jobId.employerId.toString() !== context.user.userId && !context.isAdmin) { |
| throw new GraphQLError('Unauthorized to update this application'); |
| } |
| |
| application.status = status; |
| if (employerNotes) application.employerNotes = employerNotes; |
| application.updatedAt = new Date(); |
| await application.save(); |
| |
| const notification = new Notification({ |
| userId: application.applicantId, |
| type: 'system', |
| content: `Your application for "${application.jobId.title}" has been updated to: ${status}`, |
| targetId: application._id, |
| targetType: 'application', |
| read: false |
| }); |
| await notification.save(); |
| |
| return application; |
| }, |
|
|
| scheduleInterview: async (parent, { applicationId, scheduledAt, type, meetingLink }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const application = await JobApplication.findById(applicationId).populate('jobId'); |
| if (!application) throw new GraphQLError('Application not found'); |
| if (application.jobId.employerId.toString() !== context.user.userId && !context.isAdmin) { |
| throw new GraphQLError('Unauthorized'); |
| } |
| |
| const interview = { |
| scheduledAt: new Date(scheduledAt), |
| type, |
| meetingLink: meetingLink || null, |
| status: 'scheduled' |
| }; |
| |
| application.interviews.push(interview); |
| application.status = 'interview'; |
| await application.save(); |
| |
| const notification = new Notification({ |
| userId: application.applicantId, |
| type: 'system', |
| content: `Interview scheduled for ${application.jobId.title} on ${new Date(scheduledAt).toLocaleString()}`, |
| targetId: application._id, |
| targetType: 'interview', |
| read: false |
| }); |
| await notification.save(); |
| |
| return application; |
| }, |
|
|
| saveJob: async (parent, { id }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const job = await Job.findById(id); |
| if (!job) throw new GraphQLError('Job not found'); |
| |
| const existingSave = await JobSave.findOne({ |
| jobId: job._id, |
| userId: context.user.userId |
| }); |
| |
| if (existingSave) { |
| await existingSave.deleteOne(); |
| return { success: true, count: 0, isSaved: false }; |
| } else { |
| await JobSave.create({ |
| jobId: job._id, |
| userId: context.user.userId |
| }); |
| return { success: true, count: 1, isSaved: true }; |
| } |
| }, |
|
|
| adminCreateJobCategory: async (parent, { name, nameAr, icon, color, order }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| const category = new JobCategory({ |
| name, |
| nameAr: nameAr || '', |
| icon: icon || 'bx-briefcase', |
| color: color || '#6b7280', |
| order: order || 0, |
| isActive: true |
| }); |
| |
| await category.save(); |
| return category; |
| }, |
|
|
| adminUpdateJobCategory: async (parent, { id, name, nameAr, icon, color, isActive, order }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| const category = await JobCategory.findById(id); |
| if (!category) throw new GraphQLError('Category not found'); |
| |
| if (name) category.name = name; |
| if (nameAr !== undefined) category.nameAr = nameAr; |
| if (icon) category.icon = icon; |
| if (color) category.color = color; |
| if (isActive !== undefined) category.isActive = isActive; |
| if (order !== undefined) category.order = order; |
| |
| await category.save(); |
| return category; |
| }, |
|
|
| adminDeleteJobCategory: async (parent, { id }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| const category = await JobCategory.findById(id); |
| if (!category) throw new GraphQLError('Category not found'); |
| |
| const jobsCount = await Job.countDocuments({ category: category.name }); |
| if (jobsCount > 0) { |
| throw new GraphQLError(`Cannot delete category with ${jobsCount} jobs`); |
| } |
| |
| await category.deleteOne(); |
| return true; |
| }, |
|
|
| |
| toggleStore: async (parent, { enabled }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| if (enabled) { |
| const subscription = await UserSubscription.findOne({ |
| userId: context.user.userId, |
| status: 'active' |
| }).populate('planId'); |
| |
| if (!subscription) { |
| throw new GraphQLError('You need an active subscription to enable your store'); |
| } |
| |
| const plan = subscription.planId; |
| if (!plan || !plan.features || !plan.features.storeEnabled) { |
| throw new GraphQLError('Your current plan does not include store functionality'); |
| } |
| } |
| |
| await User.findByIdAndUpdate(context.user.userId, { |
| 'profile.storeEnabled': enabled |
| }); |
| |
| await StoreSettings.findOneAndUpdate( |
| { userId: context.user.userId }, |
| { enabled, updatedAt: Date.now() }, |
| { upsert: true } |
| ); |
| |
| return enabled; |
| }, |
|
|
| updateStoreSettings: async (parent, { input }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| let settings = await StoreSettings.findOne({ userId: context.user.userId }); |
| if (!settings) { |
| settings = new StoreSettings({ userId: context.user.userId }); |
| } |
| |
| const updateData = {}; |
| if (input.storeName !== undefined) updateData.storeName = input.storeName; |
| if (input.storeLogo !== undefined) updateData.storeLogo = input.storeLogo; |
| if (input.storeBanner !== undefined) updateData.storeBanner = input.storeBanner; |
| if (input.storeDescription !== undefined) updateData.storeDescription = input.storeDescription; |
| if (input.contactEmail !== undefined) updateData.contactEmail = input.contactEmail; |
| if (input.currency !== undefined) { |
| updateData.currency = input.currency; |
| const symbols = { 'USD': '$', 'EUR': '€', 'GBP': '£', 'EGP': 'ج.م', 'SAR': '﷼', 'AED': 'د.إ' }; |
| updateData.currencySymbol = symbols[input.currency] || '$'; |
| } |
| if (input.primaryColor !== undefined) updateData.primaryColor = input.primaryColor; |
| if (input.secondaryColor !== undefined) updateData.secondaryColor = input.secondaryColor; |
| if (input.fontFamily !== undefined) updateData.fontFamily = input.fontFamily; |
| if (input.borderRadius !== undefined) updateData.borderRadius = input.borderRadius; |
| if (input.shadowType !== undefined) updateData.shadowType = input.shadowType; |
| if (input.animationType !== undefined) updateData.animationType = input.animationType; |
| if (input.glassEffect !== undefined) updateData.glassEffect = input.glassEffect; |
| if (input.backgroundType !== undefined) updateData.backgroundType = input.backgroundType; |
| if (input.backgroundValue !== undefined) updateData.backgroundValue = input.backgroundValue; |
| if (input.customCss !== undefined) updateData.customCss = input.customCss; |
| if (input.productViewType !== undefined) updateData.productViewType = input.productViewType; |
| if (input.socialLinks !== undefined) updateData.socialLinks = input.socialLinks; |
| if (input.pageBuilder !== undefined) updateData['pageBuilder.sections'] = input.pageBuilder.sections; |
| if (input.paymentMethods !== undefined) updateData.paymentMethods = input.paymentMethods; |
| |
| updateData.updatedAt = Date.now(); |
| |
| Object.assign(settings, updateData); |
| await settings.save(); |
| |
| return settings; |
| }, |
|
|
| createProduct: async (parent, { input, file }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const subscription = await UserSubscription.findOne({ |
| userId: context.user.userId, |
| status: 'active' |
| }).populate('planId'); |
| |
| if (!subscription) { |
| throw new GraphQLError('You need an active subscription to add products'); |
| } |
| |
| const plan = subscription.planId; |
| if (!plan || !plan.features || !plan.features.storeEnabled) { |
| throw new GraphQLError('Your current plan does not allow adding products'); |
| } |
| |
| const currentProductsCount = await Product.countDocuments({ |
| userId: context.user.userId, |
| isActive: true |
| }); |
| |
| const maxProducts = plan.features.maxProducts || 0; |
| if (maxProducts > 0 && currentProductsCount >= maxProducts) { |
| throw new GraphQLError(`Your plan allows up to ${maxProducts} products`); |
| } |
| |
| const product = new Product({ |
| userId: context.user.userId, |
| type: input.type, |
| title: input.title, |
| description: input.description, |
| price: input.price, |
| currency: input.currency || 'USD', |
| deliveryTime: input.deliveryTime || null, |
| tags: input.tags || [], |
| images: input.images || [], |
| isActive: input.isActive !== false |
| }); |
| |
| await product.save(); |
| |
| await StoreSettings.findOneAndUpdate( |
| { userId: context.user.userId }, |
| { $inc: { productsCount: 1 }, updatedAt: Date.now() } |
| ); |
| |
| return product; |
| }, |
|
|
| updateProduct: async (parent, { id, input }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const product = await Product.findOne({ _id: id, userId: context.user.userId }); |
| if (!product) throw new GraphQLError('Product not found'); |
| |
| const subscription = await UserSubscription.findOne({ |
| userId: context.user.userId, |
| status: 'active' |
| }).populate('planId'); |
| |
| if (!subscription) { |
| throw new GraphQLError('You need an active subscription to manage products'); |
| } |
| |
| const plan = subscription.planId; |
| if (!plan || !plan.features || !plan.features.storeEnabled) { |
| throw new GraphQLError('Your current plan does not allow managing products'); |
| } |
| |
| if (input.title !== undefined) product.title = input.title; |
| if (input.description !== undefined) product.description = input.description; |
| if (input.price !== undefined) product.price = input.price; |
| if (input.type !== undefined) product.type = input.type; |
| if (input.currency !== undefined) product.currency = input.currency; |
| if (input.deliveryTime !== undefined) product.deliveryTime = input.deliveryTime; |
| if (input.tags !== undefined) product.tags = input.tags; |
| if (input.images !== undefined) product.images = input.images; |
| if (input.isActive !== undefined) product.isActive = input.isActive; |
| |
| product.updatedAt = Date.now(); |
| await product.save(); |
| |
| return product; |
| }, |
|
|
| deleteProduct: async (parent, { id }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const product = await Product.findOne({ _id: id, userId: context.user.userId }); |
| if (!product) throw new GraphQLError('Product not found'); |
| |
| await product.deleteOne(); |
| |
| await StoreSettings.findOneAndUpdate( |
| { userId: context.user.userId }, |
| { $inc: { productsCount: -1 }, updatedAt: Date.now() } |
| ); |
| |
| return true; |
| }, |
|
|
| toggleProduct: async (parent, { id, isActive }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const product = await Product.findOneAndUpdate( |
| { _id: id, userId: context.user.userId }, |
| { isActive, updatedAt: Date.now() }, |
| { new: true } |
| ); |
| |
| if (!product) throw new GraphQLError('Product not found'); |
| return product; |
| }, |
|
|
| addProductReview: async (parent, { productId, rating, comment }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const product = await Product.findById(productId); |
| if (!product) throw new GraphQLError('Product not found'); |
| |
| const hasPurchased = await Order.findOne({ |
| buyerId: context.user.userId, |
| 'items.productId': productId, |
| status: 'completed' |
| }); |
| |
| if (!hasPurchased) { |
| throw new GraphQLError('You can only review products you have purchased'); |
| } |
| |
| const existingReview = product.reviews.find(r => r.userId.toString() === context.user.userId); |
| if (existingReview) { |
| throw new GraphQLError('You have already reviewed this product'); |
| } |
| |
| product.reviews.push({ |
| userId: context.user.userId, |
| rating, |
| comment: comment || '', |
| createdAt: new Date() |
| }); |
| |
| const totalRatings = product.reviews.length; |
| const sumRatings = product.reviews.reduce((sum, r) => sum + r.rating, 0); |
| product.averageRating = sumRatings / totalRatings; |
| |
| await product.save(); |
| |
| const allProducts = await Product.find({ |
| userId: product.userId, |
| isActive: true, |
| 'reviews.0': { $exists: true } |
| }); |
| |
| let totalStoreRating = 0; |
| let totalStoreReviews = 0; |
| for (const p of allProducts) { |
| for (const review of p.reviews) { |
| totalStoreRating += review.rating; |
| totalStoreReviews++; |
| } |
| } |
| |
| const storeAvgRating = totalStoreReviews > 0 ? totalStoreRating / totalStoreReviews : 0; |
| await StoreSettings.findOneAndUpdate( |
| { userId: product.userId }, |
| { averageRating: storeAvgRating, updatedAt: Date.now() } |
| ); |
| |
| return product.reviews[product.reviews.length - 1]; |
| }, |
|
|
| followStore: async (parent, { storeId }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const store = await User.findById(storeId); |
| if (!store || !store.profile.storeEnabled) { |
| throw new GraphQLError('Store not found'); |
| } |
| if (storeId === context.user.userId) { |
| throw new GraphQLError('You cannot follow your own store'); |
| } |
| |
| const existingFollow = await StoreFollow.findOne({ |
| userId: context.user.userId, |
| storeId |
| }); |
| |
| let isFollowing; |
| let followersCount; |
| |
| if (existingFollow) { |
| await existingFollow.deleteOne(); |
| isFollowing = false; |
| await StoreSettings.findOneAndUpdate( |
| { userId: storeId }, |
| { $inc: { followersCount: -1 }, updatedAt: Date.now() } |
| ); |
| } else { |
| await StoreFollow.create({ |
| userId: context.user.userId, |
| storeId |
| }); |
| isFollowing = true; |
| await StoreSettings.findOneAndUpdate( |
| { userId: storeId }, |
| { $inc: { followersCount: 1 }, updatedAt: Date.now() } |
| ); |
| } |
| |
| followersCount = await StoreFollow.countDocuments({ storeId }); |
| |
| return { success: true, isFollowing, followersCount }; |
| }, |
|
|
| createCoupon: async (parent, { input }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const existingCoupon = await Coupon.findOne({ code: input.code.toUpperCase() }); |
| if (existingCoupon) { |
| throw new GraphQLError('Coupon code already exists'); |
| } |
| |
| const coupon = new Coupon({ |
| storeId: context.user.userId, |
| code: input.code.toUpperCase(), |
| discountType: input.discountType, |
| discountValue: input.discountValue, |
| minPurchase: input.minPurchase || 0, |
| maxDiscount: input.maxDiscount || null, |
| validUntil: input.validUntil ? new Date(input.validUntil) : null, |
| usageLimit: input.usageLimit || null, |
| applicableProducts: input.applicableProducts || [] |
| }); |
| |
| await coupon.save(); |
| return coupon; |
| }, |
|
|
| updateCoupon: async (parent, { id, input }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const coupon = await Coupon.findOne({ _id: id, storeId: context.user.userId }); |
| if (!coupon) throw new GraphQLError('Coupon not found'); |
| |
| if (input.code) coupon.code = input.code.toUpperCase(); |
| if (input.discountType) coupon.discountType = input.discountType; |
| if (input.discountValue !== undefined) coupon.discountValue = input.discountValue; |
| if (input.minPurchase !== undefined) coupon.minPurchase = input.minPurchase; |
| if (input.maxDiscount !== undefined) coupon.maxDiscount = input.maxDiscount; |
| if (input.validUntil !== undefined) coupon.validUntil = input.validUntil ? new Date(input.validUntil) : null; |
| if (input.usageLimit !== undefined) coupon.usageLimit = input.usageLimit; |
| if (input.applicableProducts !== undefined) coupon.applicableProducts = input.applicableProducts; |
| |
| await coupon.save(); |
| return coupon; |
| }, |
|
|
| deleteCoupon: async (parent, { id }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const coupon = await Coupon.findOne({ _id: id, storeId: context.user.userId }); |
| if (!coupon) throw new GraphQLError('Coupon not found'); |
| |
| await coupon.deleteOne(); |
| return true; |
| }, |
|
|
| validateCoupon: async (parent, { code, cartTotal, storeId }) => { |
| const coupon = await Coupon.findOne({ |
| code: code.toUpperCase(), |
| storeId, |
| isActive: true |
| }); |
| |
| if (!coupon) { |
| return { valid: false, discount: 0, finalTotal: cartTotal, message: 'Invalid coupon code' }; |
| } |
| |
| const now = new Date(); |
| if (coupon.validFrom && now < coupon.validFrom) { |
| return { valid: false, discount: 0, finalTotal: cartTotal, message: 'Coupon not yet active' }; |
| } |
| if (coupon.validUntil && now > coupon.validUntil) { |
| return { valid: false, discount: 0, finalTotal: cartTotal, message: 'Coupon has expired' }; |
| } |
| if (coupon.usageLimit && coupon.usageCount >= coupon.usageLimit) { |
| return { valid: false, discount: 0, finalTotal: cartTotal, message: 'Coupon usage limit reached' }; |
| } |
| if (cartTotal < coupon.minPurchase) { |
| return { valid: false, discount: 0, finalTotal: cartTotal, message: `Minimum purchase of ${coupon.minPurchase} required` }; |
| } |
| |
| let discountAmount = 0; |
| if (coupon.discountType === 'percentage') { |
| discountAmount = (cartTotal * coupon.discountValue) / 100; |
| if (coupon.maxDiscount && discountAmount > coupon.maxDiscount) { |
| discountAmount = coupon.maxDiscount; |
| } |
| } else { |
| discountAmount = Math.min(coupon.discountValue, cartTotal); |
| } |
| |
| return { |
| valid: true, |
| discount: discountAmount, |
| finalTotal: cartTotal - discountAmount, |
| coupon |
| }; |
| }, |
|
|
| contactStore: async (parent, { input, storeId }, context) => { |
| const seller = await User.findById(storeId); |
| if (!seller) throw new GraphQLError('Store not found'); |
| |
| const storeSettings = await StoreSettings.findOne({ userId: storeId }); |
| if (!storeSettings || !storeSettings.enabled) { |
| throw new GraphQLError('Store is not active'); |
| } |
| |
| const storeContactEmail = storeSettings.contactEmail || seller.email; |
| |
| const notification = new Notification({ |
| userId: storeId, |
| type: 'contact_message', |
| actorId: context?.user?.userId || null, |
| actorName: input.name, |
| content: `New contact message from ${input.name}: "${input.message.substring(0, 80)}${input.message.length > 80 ? '...' : ''}"`, |
| targetId: `/shop/${seller.profile?.nickname || seller.username}`, |
| targetType: 'store', |
| read: false, |
| metadata: { |
| email: input.email, |
| message: input.message, |
| name: input.name |
| } |
| }); |
| await notification.save(); |
| |
| try { |
| await sendStoreEmailWithBrevo( |
| storeContactEmail, |
| `📬 New Contact Message from ${input.name}`, |
| `<div> |
| <h2>New Contact Message</h2> |
| <p><strong>Name:</strong> ${input.name}</p> |
| <p><strong>Email:</strong> ${input.email}</p> |
| <p><strong>Message:</strong></p> |
| <p>${input.message}</p> |
| </div>`, |
| `New message from ${input.name}: ${input.message}` |
| ); |
| } catch (e) {} |
| |
| return true; |
| }, |
|
|
| updatePagesOrder: async (parent, { pages }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| for (const page of pages) { |
| if (page.id && page.id !== 'products' && page.id !== 'about' && page.id !== 'contact') { |
| await Page.findOneAndUpdate( |
| { _id: page.id, storeId: context.user.userId }, |
| { order: page.order, updatedAt: Date.now() } |
| ); |
| } else if (page.id === 'about' || page.id === 'contact') { |
| const existingPage = await Page.findOne({ storeId: context.user.userId, slug: page.id }); |
| if (existingPage) { |
| await Page.findOneAndUpdate( |
| { _id: existingPage._id }, |
| { order: page.order } |
| ); |
| } else { |
| await Page.create({ |
| storeId: context.user.userId, |
| slug: page.id, |
| title: page.id === 'about' ? 'About Us' : 'Contact Us', |
| type: 'custom', |
| isDefault: true, |
| order: page.order, |
| isEnabled: true |
| }); |
| } |
| } |
| } |
| |
| const productsOrder = pages.find(p => p.id === 'products'); |
| if (productsOrder) { |
| await StoreSettings.findOneAndUpdate( |
| { userId: context.user.userId }, |
| { 'pagesOrder.products': productsOrder.order } |
| ); |
| } |
| |
| return true; |
| }, |
|
|
| uploadPage: async (parent, { file, slug, title }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| return new Page({ |
| storeId: context.user.userId, |
| slug: slug || 'page', |
| title: title || 'Uploaded Page', |
| type: 'uploaded', |
| isEnabled: true |
| }); |
| }, |
|
|
| uploadPageZip: async (parent, { file }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| return []; |
| }, |
|
|
| createPage: async (parent, { input }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const existingPage = await Page.findOne({ |
| storeId: context.user.userId, |
| slug: input.slug |
| }); |
| if (existingPage) { |
| throw new GraphQLError('Page with this slug already exists'); |
| } |
| |
| const page = new Page({ |
| storeId: context.user.userId, |
| slug: input.slug.toLowerCase().replace(/[^a-z0-9-]/g, '-'), |
| title: input.title, |
| content: input.content || '', |
| css: input.css || '', |
| js: input.js || '', |
| type: 'custom', |
| isEnabled: true, |
| order: input.order || 0, |
| seo: input.seo || {}, |
| visibility: input.visibility || 'public', |
| settings: input.settings || {} |
| }); |
| |
| await page.save(); |
| return page; |
| }, |
|
|
| updatePage: async (parent, { id, input }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const page = await Page.findOne({ _id: id, storeId: context.user.userId }); |
| if (!page) throw new GraphQLError('Page not found'); |
| |
| const updateData = { |
| title: input.title || page.title, |
| content: input.content !== undefined ? input.content : page.content, |
| css: input.css !== undefined ? input.css : page.css, |
| js: input.js !== undefined ? input.js : page.js, |
| isEnabled: input.isEnabled !== undefined ? input.isEnabled : page.isEnabled, |
| visibility: input.visibility || page.visibility, |
| settings: input.settings || page.settings, |
| updatedAt: Date.now() |
| }; |
| |
| if (input.seo) updateData.seo = { ...page.seo, ...input.seo }; |
| if (input.order !== undefined) updateData.order = input.order; |
| if (input.slug && !page.isDefault) { |
| updateData.slug = input.slug.toLowerCase().replace(/[^a-z0-9-]/g, '-'); |
| } |
| |
| Object.assign(page, updateData); |
| await page.save(); |
| |
| return page; |
| }, |
|
|
| deletePage: async (parent, { id }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const page = await Page.findOne({ _id: id, storeId: context.user.userId }); |
| if (!page) throw new GraphQLError('Page not found'); |
| |
| await page.deleteOne(); |
| return true; |
| }, |
|
|
| duplicatePage: async (parent, { id }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const originalPage = await Page.findOne({ _id: id, storeId: context.user.userId }); |
| if (!originalPage) throw new GraphQLError('Page not found'); |
| |
| let finalSlug = `${originalPage.slug}-copy`; |
| let counter = 1; |
| while (await Page.findOne({ storeId: context.user.userId, slug: finalSlug })) { |
| counter++; |
| finalSlug = `${originalPage.slug}-copy-${counter}`; |
| } |
| |
| const duplicatedPage = new Page({ |
| storeId: context.user.userId, |
| slug: finalSlug, |
| title: `${originalPage.title} (Copy)`, |
| content: originalPage.content, |
| css: originalPage.css, |
| js: originalPage.js, |
| type: 'custom', |
| isEnabled: false, |
| order: await Page.countDocuments({ storeId: context.user.userId }), |
| seo: originalPage.seo, |
| visibility: originalPage.visibility, |
| settings: originalPage.settings |
| }); |
| |
| await duplicatedPage.save(); |
| return duplicatedPage; |
| }, |
|
|
| exportPage: async (parent, { id }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const page = await Page.findOne({ _id: id, storeId: context.user.userId }); |
| if (!page) throw new GraphQLError('Page not found'); |
| |
| const user = await User.findById(context.user.userId); |
| const storeSettings = await StoreSettings.findOne({ userId: context.user.userId }); |
| |
| const html = `<!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>${page.title} - ${storeSettings?.storeName || user?.username}</title> |
| <meta name="description" content="${page.seo?.description || ''}"> |
| <meta name="keywords" content="${page.seo?.keywords || ''}"> |
| <style> |
| * { margin: 0; padding: 0; box-sizing: border-box; } |
| body { font-family: 'Inter', sans-serif; background: #f9fafb; } |
| .container { max-width: 1200px; margin: 0 auto; padding: 20px; } |
| ${page.css || ''} |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| ${page.content || ''} |
| </div> |
| <script>${page.js || ''}</script> |
| </body> |
| </html>`; |
| |
| return html; |
| }, |
|
|
| resetTemplate: async (parent, args, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| await StoreSettings.findOneAndUpdate( |
| { userId: context.user.userId }, |
| { $unset: { appliedTemplate: 1 }, $set: { updatedAt: Date.now() } } |
| ); |
| |
| await StoreTheme.findOneAndUpdate( |
| { userId: context.user.userId }, |
| { isActive: false, updatedAt: Date.now() } |
| ); |
| |
| const defaultTheme = new StoreTheme({ userId: context.user.userId }); |
| await defaultTheme.save(); |
| |
| return true; |
| }, |
|
|
| applyTemplate: async (parent, { id }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const template = await TemplateStore.findById(id); |
| if (!template) throw new GraphQLError('Template not found'); |
| |
| const themeData = template.themeData || {}; |
| const colors = themeData.colors || {}; |
| const typography = themeData.typography || {}; |
| const layout = themeData.layout || {}; |
| |
| let templateUrl = null; |
| let hasFullTemplate = false; |
| |
| if (themeData.templateUrl) { |
| templateUrl = themeData.templateUrl; |
| hasFullTemplate = true; |
| } else if (themeData.uploadedFiles?.htmlFiles) { |
| const indexFile = themeData.uploadedFiles.htmlFiles.find(f => |
| f.name === 'index.html' || f.name.endsWith('/index.html') |
| ); |
| if (indexFile && indexFile.url) { |
| templateUrl = indexFile.url; |
| hasFullTemplate = true; |
| } |
| } |
| |
| await StoreSettings.findOneAndUpdate( |
| { userId: context.user.userId }, |
| { |
| primaryColor: colors.primary || '#3b82f6', |
| secondaryColor: colors.secondary || '#8b5cf6', |
| fontFamily: typography.fontFamily || 'Inter, sans-serif', |
| borderRadius: themeData.borderRadius || 16, |
| shadowType: themeData.shadowType || layout.shadowType || 'md', |
| animationType: themeData.animationType || 'lift', |
| glassEffect: themeData.glassEffect || false, |
| backgroundType: themeData.backgroundType || 'default', |
| backgroundValue: themeData.backgroundValue || '', |
| customCss: themeData.customCss || '', |
| productViewType: themeData.productsSettings?.productViewType || 'modal', |
| appliedTemplate: { |
| slug: template.slug, |
| name: template.name, |
| templateUrl, |
| appliedAt: new Date(), |
| hasFullTemplate |
| }, |
| updatedAt: Date.now() |
| }, |
| { upsert: true } |
| ); |
| |
| await User.findByIdAndUpdate(context.user.userId, { 'profile.storeEnabled': true }); |
| await TemplateStore.recordDownload(template._id, context.user.userId, context.user.userId); |
| |
| return { |
| success: true, |
| message: `Template "${template.name}" applied successfully!`, |
| appliedSettings: { |
| primaryColor: colors.primary || '#3b82f6', |
| secondaryColor: colors.secondary || '#8b5cf6', |
| fontFamily: typography.fontFamily || 'Inter, sans-serif', |
| borderRadius: themeData.borderRadius || 16, |
| shadowType: themeData.shadowType || layout.shadowType || 'md', |
| animationType: themeData.animationType || 'lift', |
| glassEffect: themeData.glassEffect || false, |
| backgroundType: themeData.backgroundType || 'default', |
| sectionsCount: themeData.sections?.length || 0, |
| layout: layout.type || 'default', |
| productsPerRow: layout.columns || themeData.productsSettings?.productsPerRow || 4, |
| hasCustomCss: !!themeData.customCss, |
| hasCustomHeader: !!themeData.customHeaderFooter?.header, |
| hasFullTemplate, |
| templateUrl |
| }, |
| needsRefresh: true, |
| redirectUrl: `/shop/${context.user.username || context.user.userId}` |
| }; |
| }, |
|
|
| uploadTheme: async (parent, { file }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| return new StoreTheme({ userId: context.user.userId, name: 'Uploaded Theme' }); |
| }, |
|
|
| updateTheme: async (parent, { input }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| let theme = await StoreTheme.findOne({ userId: context.user.userId }); |
| if (!theme) { |
| theme = new StoreTheme({ userId: context.user.userId }); |
| } |
| |
| if (input.name) theme.name = input.name; |
| if (input.isActive !== undefined) theme.isActive = input.isActive; |
| if (input.version) theme.version = input.version; |
| if (input.layout) theme.layout = input.layout; |
| if (input.customTemplates) { |
| theme.customTemplates = { ...theme.customTemplates, ...input.customTemplates }; |
| } |
| if (input.settings) { |
| theme.settings = { ...theme.settings, ...input.settings }; |
| } |
| if (input.colors) { |
| theme.colors = { ...theme.colors, ...input.colors }; |
| } |
| if (input.typography) { |
| theme.typography = { ...theme.typography, ...input.typography }; |
| } |
| if (input.customAssets) { |
| theme.customAssets = { ...theme.customAssets, ...input.customAssets }; |
| } |
| if (input.seo) { |
| theme.seo = { ...theme.seo, ...input.seo }; |
| } |
| |
| await theme.recordChange(); |
| await theme.save(); |
| |
| return theme; |
| }, |
|
|
| resetTheme: async (parent, args, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| const theme = await StoreTheme.resetToDefault(context.user.userId); |
| return theme; |
| }, |
|
|
| updateLayoutTemplate: async (parent, { layout, input }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const validLayouts = ['default', 'sidebar-left', 'sidebar-right', 'product-focused', 'minimal', 'magazine', 'compact', 'dark-store']; |
| if (!validLayouts.includes(layout)) { |
| throw new GraphQLError('Invalid layout template'); |
| } |
| |
| const updateData = { |
| 'layoutSettings.layoutTemplate': layout, |
| updatedAt: Date.now() |
| }; |
| |
| if (input) { |
| if (input.headerLayout) updateData['layoutSettings.headerLayout'] = input.headerLayout; |
| if (input.footerLayout) updateData['layoutSettings.footerLayout'] = input.footerLayout; |
| if (input.sidebarPosition) updateData['layoutSettings.sidebarPosition'] = input.sidebarPosition; |
| if (input.productsPerRowDesktop) updateData['layoutSettings.productsPerRowDesktop'] = input.productsPerRowDesktop; |
| if (input.productsPerRowTablet) updateData['layoutSettings.productsPerRowTablet'] = input.productsPerRowTablet; |
| if (input.productsPerRowMobile) updateData['layoutSettings.productsPerRowMobile'] = input.productsPerRowMobile; |
| if (input.enableStickyAddToCart !== undefined) { |
| updateData['layoutSettings.enableStickyAddToCart'] = input.enableStickyAddToCart; |
| } |
| if (input.enableQuickView !== undefined) { |
| updateData['layoutSettings.enableQuickView'] = input.enableQuickView; |
| } |
| if (input.enableCompareProducts !== undefined) { |
| updateData['layoutSettings.enableCompareProducts'] = input.enableCompareProducts; |
| } |
| if (input.enableWishlist !== undefined) { |
| updateData['layoutSettings.enableWishlist'] = input.enableWishlist; |
| } |
| if (input.enableRecentlyViewed !== undefined) { |
| updateData['layoutSettings.enableRecentlyViewed'] = input.enableRecentlyViewed; |
| } |
| if (input.enableProductReviews !== undefined) { |
| updateData['layoutSettings.enableProductReviews'] = input.enableProductReviews; |
| } |
| if (input.cardStyle) updateData['layoutSettings.cardStyle'] = input.cardStyle; |
| if (input.heroStyle) updateData['layoutSettings.heroStyle'] = input.heroStyle; |
| if (input.showStats !== undefined) updateData['layoutSettings.showStats'] = input.showStats; |
| if (input.showAnalytics !== undefined) updateData['layoutSettings.showAnalytics'] = input.showAnalytics; |
| if (input.showHero !== undefined) updateData['layoutSettings.showHero'] = input.showHero; |
| if (input.showFeatured !== undefined) updateData['layoutSettings.showFeatured'] = input.showFeatured; |
| if (input.showCategories !== undefined) updateData['layoutSettings.showCategories'] = input.showCategories; |
| if (input.theme) updateData['layoutSettings.theme'] = input.theme; |
| if (input.productViewType) updateData['layoutSettings.productViewType'] = input.productViewType; |
| if (input.compactMode) updateData['layoutSettings.compactMode'] = input.compactMode; |
| if (input.magazineMode) updateData['layoutSettings.magazineMode'] = input.magazineMode; |
| if (input.darkStoreMode) updateData['layoutSettings.darkStoreMode'] = input.darkStoreMode; |
| if (input.sectionsOrder) updateData['layoutSettings.sectionsOrder'] = input.sectionsOrder; |
| if (input.advanced) updateData['layoutSettings.advanced'] = input.advanced; |
| } |
| |
| const settings = await StoreSettings.findOneAndUpdate( |
| { userId: context.user.userId }, |
| { $set: updateData }, |
| { upsert: true, new: true } |
| ); |
| |
| return settings; |
| }, |
|
|
| resetLayoutTemplate: async (parent, args, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const defaultSettings = { |
| 'layoutSettings.layoutTemplate': 'default', |
| 'layoutSettings.sidebarPosition': 'none', |
| 'layoutSettings.productsPerRowDesktop': 4, |
| 'layoutSettings.productsPerRowTablet': 3, |
| 'layoutSettings.productsPerRowMobile': 2, |
| 'layoutSettings.headerLayout': 'default', |
| 'layoutSettings.footerLayout': 'default', |
| 'layoutSettings.enableStickyAddToCart': true, |
| 'layoutSettings.enableQuickView': true, |
| 'layoutSettings.enableCompareProducts': false, |
| 'layoutSettings.enableWishlist': false, |
| 'layoutSettings.enableRecentlyViewed': true, |
| 'layoutSettings.enableProductReviews': true, |
| 'productViewType': 'modal', |
| 'primaryColor': '#3b82f6', |
| 'secondaryColor': '#8b5cf6', |
| 'fontFamily': 'Inter, sans-serif', |
| 'borderRadius': 16, |
| 'shadowType': 'md', |
| 'animationType': 'lift', |
| 'glassEffect': false, |
| 'backgroundType': 'default', |
| 'backgroundValue': '', |
| 'updatedAt': Date.now() |
| }; |
| |
| const settings = await StoreSettings.findOneAndUpdate( |
| { userId: context.user.userId }, |
| { $set: defaultSettings }, |
| { upsert: true, new: true } |
| ); |
| |
| return settings; |
| }, |
|
|
| |
| addToCart: async (parent, { productId, storeId, quantity }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| if (quantity < 1 || quantity > 99) { |
| throw new GraphQLError('Quantity must be between 1 and 99'); |
| } |
| |
| let cart = await Cart.findOne({ userId: context.user.userId }); |
| if (!cart) { |
| cart = new Cart({ userId: context.user.userId, stores: new Map() }); |
| } |
| |
| const product = await Product.findById(productId); |
| if (!product) throw new GraphQLError('Product not found'); |
| |
| let storeCart = cart.stores.get(storeId); |
| if (!storeCart) { |
| storeCart = { |
| storeId, |
| storeName: '', |
| storeLogo: '', |
| items: [], |
| subtotal: 0, |
| itemCount: 0, |
| createdAt: new Date(), |
| updatedAt: new Date() |
| }; |
| } |
| |
| const existingItemIndex = storeCart.items.findIndex( |
| item => item.productId?.toString() === productId?.toString() |
| ); |
| |
| if (existingItemIndex !== -1) { |
| const newQuantity = storeCart.items[existingItemIndex].quantity + quantity; |
| storeCart.items[existingItemIndex].quantity = Math.min(newQuantity, 99); |
| } else { |
| storeCart.items.push({ |
| productId, |
| title: product.title, |
| price: product.price, |
| quantity: Math.min(quantity, 99), |
| image: product.images?.[0] || '/assets/img/default-product.png', |
| productType: product.type || 'digital' |
| }); |
| } |
| |
| let subtotal = 0; |
| let itemCount = 0; |
| for (const item of storeCart.items) { |
| subtotal += item.price * item.quantity; |
| itemCount += item.quantity; |
| } |
| storeCart.subtotal = subtotal; |
| storeCart.itemCount = itemCount; |
| storeCart.updatedAt = new Date(); |
| |
| cart.stores.set(storeId, storeCart); |
| await cart.calculateTotals(); |
| await cart.save(); |
| |
| return cart; |
| }, |
|
|
| updateCartItem: async (parent, { storeId, productId, quantity }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| if (quantity < 1 || quantity > 99) { |
| throw new GraphQLError('Quantity must be between 1 and 99'); |
| } |
| |
| const cart = await Cart.findOne({ userId: context.user.userId }); |
| if (!cart) throw new GraphQLError('Cart not found'); |
| |
| const storeCart = cart.stores.get(storeId); |
| if (!storeCart) throw new GraphQLError('Store not found in cart'); |
| |
| const itemIndex = storeCart.items.findIndex( |
| item => item.productId?.toString() === productId?.toString() |
| ); |
| if (itemIndex === -1) throw new GraphQLError('Product not found in cart'); |
| |
| storeCart.items[itemIndex].quantity = quantity; |
| |
| let subtotal = 0; |
| let itemCount = 0; |
| for (const item of storeCart.items) { |
| subtotal += item.price * item.quantity; |
| itemCount += item.quantity; |
| } |
| storeCart.subtotal = subtotal; |
| storeCart.itemCount = itemCount; |
| storeCart.updatedAt = new Date(); |
| |
| cart.stores.set(storeId, storeCart); |
| await cart.calculateTotals(); |
| await cart.save(); |
| |
| return cart; |
| }, |
|
|
| removeFromCart: async (parent, { storeId, productId }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const cart = await Cart.findOne({ userId: context.user.userId }); |
| if (!cart) throw new GraphQLError('Cart not found'); |
| |
| const storeCart = cart.stores.get(storeId); |
| if (!storeCart) throw new GraphQLError('Store not found in cart'); |
| |
| storeCart.items = storeCart.items.filter( |
| item => item.productId?.toString() !== productId?.toString() |
| ); |
| |
| if (storeCart.items.length === 0) { |
| cart.stores.delete(storeId); |
| } else { |
| let subtotal = 0; |
| let itemCount = 0; |
| for (const item of storeCart.items) { |
| subtotal += item.price * item.quantity; |
| itemCount += item.quantity; |
| } |
| storeCart.subtotal = subtotal; |
| storeCart.itemCount = itemCount; |
| storeCart.updatedAt = new Date(); |
| cart.stores.set(storeId, storeCart); |
| } |
| |
| await cart.calculateTotals(); |
| await cart.save(); |
| |
| return cart; |
| }, |
|
|
| clearStoreCart: async (parent, { storeId }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const cart = await Cart.findOne({ userId: context.user.userId }); |
| if (!cart) throw new GraphQLError('Cart not found'); |
| |
| cart.stores.delete(storeId); |
| await cart.calculateTotals(); |
| await cart.save(); |
| |
| return cart; |
| }, |
|
|
| clearCart: async (parent, args, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| await Cart.findOneAndDelete({ userId: context.user.userId }); |
| return true; |
| }, |
|
|
| |
| createOrder: async (parent, { input }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const { storeId, items, buyerNotes, paymentMethod } = input; |
| |
| const cart = await Cart.findOne({ userId: context.user.userId }); |
| if (!cart) throw new GraphQLError('Cart is empty'); |
| |
| const storeCart = cart.stores.get(storeId); |
| if (!storeCart || storeCart.items.length === 0) { |
| throw new GraphQLError('Store cart is empty'); |
| } |
| |
| const storeSettings = await StoreSettings.findOne({ userId: storeId }); |
| if (!storeSettings || !storeSettings.enabled) { |
| throw new GraphQLError('Store is not active'); |
| } |
| |
| const paymentMethodData = storeSettings.paymentMethods?.find( |
| m => m.type === paymentMethod |
| ); |
| if (!paymentMethodData) { |
| throw new GraphQLError('Payment method not available'); |
| } |
| |
| const order = new Order({ |
| orderNumber: `ORD-${Date.now()}-${Math.floor(Math.random() * 10000)}`, |
| buyerId: context.user.userId, |
| sellerId: storeId, |
| items: storeCart.items.map(item => ({ |
| productId: item.productId, |
| productType: item.productType, |
| title: item.title, |
| price: item.price, |
| quantity: item.quantity |
| })), |
| subtotal: storeCart.subtotal, |
| totalAmount: storeCart.subtotal, |
| currency: storeSettings.currency || 'USD', |
| status: 'pending', |
| paymentMethod: paymentMethodData.type, |
| paymentDetails: paymentMethodData.details || {}, |
| buyerNotes: buyerNotes || '', |
| createdAt: new Date() |
| }); |
| |
| await order.save(); |
| |
| for (const item of storeCart.items) { |
| await Product.findByIdAndUpdate(item.productId, { |
| $inc: { salesCount: item.quantity } |
| }); |
| } |
| |
| cart.stores.delete(storeId); |
| await cart.calculateTotals(); |
| await cart.save(); |
| |
| return order; |
| }, |
|
|
| updateOrderStatus: async (parent, { id, status, sellerNotes }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const order = await Order.findOne({ _id: id, sellerId: context.user.userId }); |
| if (!order) throw new GraphQLError('Order not found'); |
| |
| order.status = status; |
| if (sellerNotes) order.sellerNotes = sellerNotes; |
| if (status === 'completed') order.deliveredAt = new Date(); |
| order.updatedAt = new Date(); |
| await order.save(); |
| |
| return order; |
| }, |
|
|
| cancelOrder: async (parent, { id, reason }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const order = await Order.findOne({ _id: id, buyerId: context.user.userId }); |
| if (!order) throw new GraphQLError('Order not found'); |
| if (order.status !== 'pending') { |
| throw new GraphQLError('Cannot cancel order in current status'); |
| } |
| |
| order.status = 'cancelled'; |
| order.cancelledAt = new Date(); |
| order.cancellationReason = reason || 'Cancelled by user'; |
| await order.save(); |
| |
| return order; |
| }, |
|
|
| uploadOrderFile: async (parent, { orderId, productId, file }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| const order = await Order.findOne({ _id: orderId, sellerId: context.user.userId }); |
| if (!order) throw new GraphQLError('Order not found or unauthorized'); |
| |
| const item = order.items.find(i => i.productId.toString() === productId); |
| if (!item) throw new GraphQLError('Product not found in this order'); |
| |
| return item; |
| }, |
|
|
| addOrderFileUrl: async (parent, { orderId, productId, fileUrl, fileName }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const order = await Order.findOne({ _id: orderId, sellerId: context.user.userId }); |
| if (!order) throw new GraphQLError('Order not found or unauthorized'); |
| |
| const item = order.items.find(i => i.productId.toString() === productId); |
| if (!item) throw new GraphQLError('Product not found in this order'); |
| |
| item.fileUrl = fileUrl; |
| item.fileName = fileName || item.title + '.file'; |
| item.fileType = 'link'; |
| item.uploadedAt = new Date(); |
| |
| await order.save(); |
| return item; |
| }, |
|
|
| downloadOrderFile: async (parent, { orderId, productId }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const order = await Order.findById(orderId); |
| if (!order) throw new GraphQLError('Order not found'); |
| if (order.buyerId.toString() !== context.user.userId || order.status !== 'completed') { |
| throw new GraphQLError('Unauthorized or order not completed'); |
| } |
| |
| const item = order.items.find(i => i.productId.toString() === productId); |
| if (!item || !item.fileUrl) { |
| throw new GraphQLError('File not found'); |
| } |
| |
| await Order.findByIdAndUpdate(orderId, { |
| $inc: { 'items.$[item].downloadCount': 1 } |
| }, { |
| arrayFilters: [{ 'item.productId': productId }] |
| }); |
| |
| return item.fileUrl; |
| }, |
|
|
| |
| followUser: async (parent, { userId }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| if (userId === context.user.userId) { |
| throw new GraphQLError('Cannot follow yourself'); |
| } |
| |
| const existingFollow = await Follow.findOne({ |
| followerId: context.user.userId, |
| followingId: userId |
| }); |
| |
| if (existingFollow) { |
| throw new GraphQLError('Already following this user'); |
| } |
| |
| const follow = new Follow({ |
| followerId: context.user.userId, |
| followingId: userId, |
| status: 'accepted' |
| }); |
| await follow.save(); |
| |
| const user = await User.findById(context.user.userId); |
| const notification = new Notification({ |
| userId, |
| type: 'follow', |
| actorId: context.user.userId, |
| actorName: user.profile?.nickname || user.username, |
| actorAvatar: user.profile?.avatar, |
| targetId: context.user.userId, |
| targetType: 'user', |
| content: `started following you`, |
| read: false |
| }); |
| await notification.save(); |
| |
| return { success: true, isFollowing: true }; |
| }, |
|
|
| unfollowUser: async (parent, { userId }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const follow = await Follow.findOneAndDelete({ |
| followerId: context.user.userId, |
| followingId: userId |
| }); |
| |
| if (!follow) throw new GraphQLError('Not following this user'); |
| |
| await Notification.deleteMany({ |
| userId, |
| type: 'follow', |
| actorId: context.user.userId |
| }); |
| |
| return { success: true, isFollowing: false }; |
| }, |
|
|
| acceptFollowRequest: async (parent, { requestId }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const follow = await Follow.findById(requestId); |
| if (!follow) throw new GraphQLError('Request not found'); |
| if (follow.followingId.toString() !== context.user.userId) { |
| throw new GraphQLError('Unauthorized'); |
| } |
| |
| follow.status = 'accepted'; |
| await follow.save(); |
| |
| const notification = new Notification({ |
| userId: follow.followerId, |
| type: 'follow', |
| actorId: context.user.userId, |
| content: `accepted your follow request`, |
| read: false |
| }); |
| await notification.save(); |
| |
| return true; |
| }, |
|
|
| declineFollowRequest: async (parent, { requestId }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const follow = await Follow.findById(requestId); |
| if (!follow) throw new GraphQLError('Request not found'); |
| if (follow.followingId.toString() !== context.user.userId) { |
| throw new GraphQLError('Unauthorized'); |
| } |
| |
| await follow.deleteOne(); |
| return true; |
| }, |
|
|
| |
| markNotificationRead: async (parent, { id }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const notification = await Notification.findOneAndUpdate( |
| { _id: id, userId: context.user.userId }, |
| { read: true }, |
| { new: true } |
| ); |
| |
| if (!notification) throw new GraphQLError('Notification not found'); |
| return notification; |
| }, |
|
|
| markAllNotificationsRead: async (parent, args, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| await Notification.updateMany( |
| { userId: context.user.userId, read: false }, |
| { $set: { read: true } } |
| ); |
| |
| return true; |
| }, |
|
|
| deleteNotification: async (parent, { id }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const notification = await Notification.findOneAndDelete({ |
| _id: id, |
| userId: context.user.userId |
| }); |
| |
| if (!notification) throw new GraphQLError('Notification not found'); |
| return true; |
| }, |
|
|
| |
| rateUser: async (parent, { targetUserId, rating, review }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| if (targetUserId === context.user.userId) { |
| throw new GraphQLError('You cannot rate yourself'); |
| } |
| |
| const targetUser = await User.findById(targetUserId); |
| if (!targetUser) throw new GraphQLError('User not found'); |
| |
| const existingRating = await Rating.findOne({ targetUserId, raterUserId: context.user.userId }); |
| |
| if (existingRating) { |
| existingRating.rating = rating; |
| existingRating.review = review || existingRating.review; |
| existingRating.updatedAt = new Date(); |
| await existingRating.save(); |
| } else { |
| const newRating = new Rating({ |
| targetUserId, |
| raterUserId: context.user.userId, |
| rating, |
| review: review || '' |
| }); |
| await newRating.save(); |
| } |
| |
| const allRatings = await Rating.find({ targetUserId }); |
| const averageRating = allRatings.reduce((sum, r) => sum + r.rating, 0) / allRatings.length; |
| |
| await User.findByIdAndUpdate(targetUserId, { |
| 'profile.averageRating': averageRating, |
| 'profile.totalRatings': allRatings.length |
| }); |
| |
| return { |
| success: true, |
| averageRating, |
| totalRatings: allRatings.length |
| }; |
| }, |
|
|
| |
| askAI: async (parent, { nickname, question, language }, context) => { |
| const startTime = Date.now(); |
| |
| if (question.trim().length < 2) { |
| throw new GraphQLError('Question is too short'); |
| } |
| if (question.trim().length > 1000) { |
| throw new GraphQLError('Question is too long'); |
| } |
| |
| const user = await User.findOne({ |
| $or: [ |
| { 'profile.nickname': { $regex: `^${nickname}$`, $options: 'i' } }, |
| { username: { $regex: `^${nickname}$`, $options: 'i' } } |
| ] |
| }); |
| |
| if (!user) throw new GraphQLError('User not found'); |
| |
| const aiConfig = user.profile?.aiBot || { enabled: false }; |
| if (!aiConfig.enabled) { |
| throw new GraphQLError('AI Bot is not enabled for this profile'); |
| } |
| |
| const isOwner = context.user && context.user.userId === user._id.toString(); |
| const targetLanguage = language || detectLanguage(question); |
| |
| if (isOwner) { |
| await User.updateOne( |
| { _id: user._id }, |
| { |
| $inc: { 'profile.aiBot.totalQueries': 1 }, |
| $set: { 'profile.aiBot.lastQueryAt': new Date() } |
| } |
| ); |
| |
| return { |
| answer: 'AI response would be generated here', |
| language: targetLanguage, |
| provider: 'mgzon', |
| responseTime: Date.now() - startTime, |
| isCommand: false |
| }; |
| } |
| |
| const visitorIp = context.req?.ip || context.req?.headers?.['x-forwarded-for'] || ''; |
| |
| await saveAIConversation( |
| user._id, |
| question, |
| 'AI response', |
| 'mgzon', |
| targetLanguage, |
| Date.now() - startTime, |
| visitorIp |
| ); |
| |
| return { |
| answer: 'AI response for visitor', |
| language: targetLanguage, |
| provider: 'mgzon', |
| responseTime: Date.now() - startTime, |
| isCommand: false |
| }; |
| }, |
|
|
| clearAICache: async (parent, args, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| return true; |
| }, |
|
|
| |
| subscribe: async (parent, { planId, paymentMethodId, paymentDetails, autoRenew, paymentProof }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const plan = await SubscriptionPlan.findById(planId); |
| if (!plan || !plan.isActive) throw new GraphQLError('Plan not found'); |
| |
| const paymentMethod = await PlatformPaymentMethod.findById(paymentMethodId); |
| if (!paymentMethod || !paymentMethod.isActive) throw new GraphQLError('Payment method not found'); |
| |
| const existingSubscription = await UserSubscription.findOne({ |
| userId: context.user.userId, |
| status: { $in: ['active', 'pending'] } |
| }); |
| |
| let isTrial = false; |
| let trialEndDate = null; |
| |
| if (plan.hasFreeTrial && !existingSubscription) { |
| isTrial = true; |
| trialEndDate = new Date(); |
| trialEndDate.setDate(trialEndDate.getDate() + plan.freeTrialDays); |
| } |
| |
| const startDate = isTrial ? new Date() : null; |
| const endDate = isTrial ? trialEndDate : null; |
| |
| const subscription = new UserSubscription({ |
| userId: context.user.userId, |
| planId, |
| status: isTrial ? 'active' : 'pending', |
| startDate, |
| endDate, |
| isTrial, |
| trialEndDate, |
| paymentMethod: paymentMethod.name, |
| paymentDetails: paymentDetails || {}, |
| amount: isTrial ? 0 : plan.price, |
| currency: plan.currency, |
| autoRenew: autoRenew || false, |
| createdAt: new Date() |
| }); |
| |
| await subscription.save(); |
| |
| if (isTrial) { |
| await updateUserPermissions(context.user.userId, plan); |
| } |
| |
| return subscription; |
| }, |
|
|
| renewSubscription: async (parent, { planId, paymentMethodId, paymentDetails, paymentProof }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const plan = await SubscriptionPlan.findById(planId); |
| if (!plan || !plan.isActive) throw new GraphQLError('Plan not found'); |
| |
| const paymentMethod = await PlatformPaymentMethod.findById(paymentMethodId); |
| if (!paymentMethod || !paymentMethod.isActive) throw new GraphQLError('Payment method not found'); |
| |
| const startDate = new Date(); |
| const endDate = new Date(); |
| endDate.setDate(endDate.getDate() + plan.durationDays); |
| |
| const subscription = new UserSubscription({ |
| userId: context.user.userId, |
| planId, |
| status: plan.price === 0 ? 'active' : 'pending', |
| startDate, |
| endDate, |
| isTrial: false, |
| paymentMethod: paymentMethod.name, |
| paymentDetails: paymentDetails || {}, |
| amount: plan.price, |
| currency: plan.currency, |
| autoRenew: false, |
| createdAt: new Date() |
| }); |
| |
| await subscription.save(); |
| |
| if (plan.price === 0) { |
| await updateUserPermissions(context.user.userId, plan); |
| } |
| |
| return subscription; |
| }, |
|
|
| cancelSubscription: async (parent, { reason }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const subscription = await UserSubscription.findOne({ |
| userId: context.user.userId, |
| status: 'active' |
| }).populate('planId'); |
| |
| if (!subscription) throw new GraphQLError('No active subscription found'); |
| |
| subscription.status = 'cancelled'; |
| subscription.cancelledAt = new Date(); |
| subscription.cancellationReason = reason || 'Cancelled by user'; |
| subscription.autoRenew = false; |
| subscription.updatedAt = new Date(); |
| await subscription.save(); |
| |
| await User.findByIdAndUpdate(context.user.userId, { 'profile.storeEnabled': false }); |
| |
| return true; |
| }, |
|
|
| adminApproveSubscription: async (parent, { id, adminNotes }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| const subscription = await UserSubscription.findById(id).populate('planId'); |
| if (!subscription) throw new GraphQLError('Subscription not found'); |
| |
| const startDate = new Date(); |
| const endDate = new Date(); |
| endDate.setDate(endDate.getDate() + subscription.planId.durationDays); |
| |
| subscription.status = 'active'; |
| subscription.startDate = startDate; |
| subscription.endDate = endDate; |
| subscription.adminNotes = adminNotes || ''; |
| subscription.approvedBy = context.user.userId; |
| subscription.approvedAt = new Date(); |
| subscription.updatedAt = new Date(); |
| await subscription.save(); |
| |
| if (subscription.amount > 0) { |
| const earning = new SubscriptionEarnings({ |
| subscriptionId: subscription._id, |
| userId: subscription.userId, |
| planId: subscription.planId._id, |
| amount: subscription.amount, |
| currency: subscription.currency, |
| status: 'completed', |
| paymentMethod: subscription.paymentMethod, |
| transactionId: subscription.transactionId, |
| payoutStatus: 'pending' |
| }); |
| await earning.save(); |
| } |
| |
| await updateUserPermissions(subscription.userId, subscription.planId); |
| |
| return subscription; |
| }, |
|
|
| adminRejectSubscription: async (parent, { id, rejectionReason }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| const subscription = await UserSubscription.findById(id).populate('planId'); |
| if (!subscription) throw new GraphQLError('Subscription not found'); |
| |
| subscription.status = 'cancelled'; |
| subscription.cancelledAt = new Date(); |
| subscription.cancellationReason = rejectionReason || 'Rejected by admin'; |
| subscription.adminNotes = rejectionReason || ''; |
| subscription.updatedAt = new Date(); |
| await subscription.save(); |
| |
| return true; |
| }, |
|
|
| adminCancelSubscription: async (parent, { id, reason }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| const subscription = await UserSubscription.findById(id).populate('planId'); |
| if (!subscription) throw new GraphQLError('Subscription not found'); |
| |
| subscription.status = 'cancelled'; |
| subscription.cancelledAt = new Date(); |
| subscription.cancellationReason = reason || 'Cancelled by admin'; |
| subscription.updatedAt = new Date(); |
| await subscription.save(); |
| |
| await updateUserPermissions(subscription.userId, null); |
| |
| return true; |
| }, |
|
|
| adminCreatePlan: async (parent, { input }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| const existingPlan = await SubscriptionPlan.findOne({ name: input.name }); |
| if (existingPlan) throw new GraphQLError('Plan name already exists'); |
| |
| const plan = new SubscriptionPlan({ |
| name: input.name, |
| nameAr: input.nameAr || '', |
| description: input.description || '', |
| descriptionAr: input.descriptionAr || '', |
| price: input.price, |
| currency: input.currency || 'USD', |
| currencySymbol: input.currency === 'USD' ? '$' : input.currency === 'EUR' ? '€' : 'ج.م', |
| duration: input.duration || 'monthly', |
| durationDays: input.duration === 'monthly' ? 30 : input.duration === 'yearly' ? 365 : 0, |
| features: { |
| storeEnabled: input.features.storeEnabled || false, |
| maxProducts: input.features.maxProducts || 0, |
| maxDigitalProducts: input.features.maxDigitalProducts || 0, |
| maxProjects: input.features.maxProjects || 0, |
| maxServices: input.features.maxServices || 0, |
| pageBuilderEnabled: input.features.pageBuilderEnabled || false, |
| customDomain: input.features.customDomain || false, |
| analyticsEnabled: input.features.analyticsEnabled || false, |
| prioritySupport: input.features.prioritySupport || false, |
| removeBranding: input.features.removeBranding || false, |
| teamMembers: input.features.teamMembers || 1, |
| apiAccess: input.features.apiAccess || false, |
| customCss: input.features.customCss || false, |
| advancedAnalytics: input.features.advancedAnalytics || false, |
| exportData: input.features.exportData || false |
| }, |
| hasFreeTrial: input.hasFreeTrial || false, |
| freeTrialDays: input.freeTrialDays || 0, |
| badge: input.badge || null, |
| icon: input.icon || 'bx-star', |
| isActive: input.isActive !== false, |
| order: input.order || 0, |
| customPermissions: input.customPermissions || {} |
| }); |
| |
| await plan.save(); |
| return plan; |
| }, |
|
|
| adminUpdatePlan: async (parent, { id, input }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| const plan = await SubscriptionPlan.findById(id); |
| if (!plan) throw new GraphQLError('Plan not found'); |
| |
| if (input.name) plan.name = input.name; |
| if (input.nameAr !== undefined) plan.nameAr = input.nameAr; |
| if (input.description !== undefined) plan.description = input.description; |
| if (input.descriptionAr !== undefined) plan.descriptionAr = input.descriptionAr; |
| if (input.price !== undefined) plan.price = input.price; |
| if (input.currency) plan.currency = input.currency; |
| if (input.duration) { |
| plan.duration = input.duration; |
| plan.durationDays = input.duration === 'monthly' ? 30 : input.duration === 'yearly' ? 365 : 0; |
| } |
| if (input.features) { |
| plan.features = { ...plan.features, ...input.features }; |
| } |
| if (input.hasFreeTrial !== undefined) plan.hasFreeTrial = input.hasFreeTrial; |
| if (input.freeTrialDays !== undefined) plan.freeTrialDays = input.freeTrialDays; |
| if (input.badge !== undefined) plan.badge = input.badge; |
| if (input.icon) plan.icon = input.icon; |
| if (input.isActive !== undefined) plan.isActive = input.isActive; |
| if (input.order !== undefined) plan.order = input.order; |
| if (input.customPermissions !== undefined) plan.customPermissions = input.customPermissions; |
| |
| plan.updatedAt = Date.now(); |
| await plan.save(); |
| return plan; |
| }, |
|
|
| adminDeletePlan: async (parent, { id }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| const activeSubscriptions = await UserSubscription.countDocuments({ |
| planId: id, |
| status: { $in: ['active', 'pending'] } |
| }); |
| |
| if (activeSubscriptions > 0) { |
| throw new GraphQLError(`Cannot delete plan with ${activeSubscriptions} active subscriptions`); |
| } |
| |
| await SubscriptionPlan.findByIdAndDelete(id); |
| return true; |
| }, |
|
|
| adminCreatePaymentMethod: async (parent, { input }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| const method = new PlatformPaymentMethod({ |
| name: input.name, |
| nameAr: input.nameAr || '', |
| type: input.type, |
| icon: input.icon || (input.type === 'bank' ? 'bx-bank' : input.type === 'mobile_wallet' ? 'bx-mobile-alt' : 'bx-credit-card'), |
| instructions: input.instructions || '', |
| instructionsAr: input.instructionsAr || '', |
| bankDetails: input.bankDetails || {}, |
| mobileWalletDetails: input.mobileWalletDetails || {}, |
| onlineDetails: input.onlineDetails || {}, |
| isActive: input.isActive !== false, |
| order: input.order || 0 |
| }); |
| |
| await method.save(); |
| return method; |
| }, |
|
|
| adminUpdatePaymentMethod: async (parent, { id, input }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| const method = await PlatformPaymentMethod.findById(id); |
| if (!method) throw new GraphQLError('Payment method not found'); |
| |
| if (input.name) method.name = input.name; |
| if (input.nameAr !== undefined) method.nameAr = input.nameAr; |
| if (input.instructions !== undefined) method.instructions = input.instructions; |
| if (input.instructionsAr !== undefined) method.instructionsAr = input.instructionsAr; |
| if (input.bankDetails) method.bankDetails = { ...method.bankDetails, ...input.bankDetails }; |
| if (input.mobileWalletDetails) { |
| method.mobileWalletDetails = { ...method.mobileWalletDetails, ...input.mobileWalletDetails }; |
| } |
| if (input.onlineDetails) { |
| method.onlineDetails = { ...method.onlineDetails, ...input.onlineDetails }; |
| } |
| if (input.isActive !== undefined) method.isActive = input.isActive; |
| if (input.order !== undefined) method.order = input.order; |
| |
| method.updatedAt = Date.now(); |
| await method.save(); |
| return method; |
| }, |
|
|
| adminDeletePaymentMethod: async (parent, { id }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| await PlatformPaymentMethod.findByIdAndDelete(id); |
| return true; |
| }, |
|
|
| |
| adminToggleStore: async (parent, { userId, enabled }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| await StoreSettings.findOneAndUpdate( |
| { userId }, |
| { enabled, updatedAt: Date.now() }, |
| { upsert: true } |
| ); |
| |
| await User.findByIdAndUpdate(userId, { 'profile.storeEnabled': enabled }); |
| return true; |
| }, |
|
|
| adminDeleteStore: async (parent, { userId }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| await Product.deleteMany({ userId }); |
| await Order.deleteMany({ sellerId: userId }); |
| await StoreSettings.findOneAndDelete({ userId }); |
| await User.findByIdAndUpdate(userId, { 'profile.storeEnabled': false }); |
| return true; |
| }, |
|
|
| adminToggleProduct: async (parent, { productId, isActive }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| const product = await Product.findByIdAndUpdate( |
| productId, |
| { isActive, updatedAt: Date.now() }, |
| { new: true } |
| ); |
| |
| if (!product) throw new GraphQLError('Product not found'); |
| return product; |
| }, |
|
|
| adminDeleteProduct: async (parent, { productId }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| const product = await Product.findByIdAndDelete(productId); |
| if (!product) throw new GraphQLError('Product not found'); |
| return true; |
| }, |
|
|
| adminUpdateOrderStatus: async (parent, { orderId, status, adminNotes }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| const order = await Order.findByIdAndUpdate( |
| orderId, |
| { status, adminNotes, updatedAt: Date.now() }, |
| { new: true } |
| ); |
| |
| if (!order) throw new GraphQLError('Order not found'); |
| return order; |
| }, |
|
|
| adminMakeUser: async (parent, { userId, isAdmin }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| const user = await User.findByIdAndUpdate( |
| userId, |
| { isAdmin }, |
| { new: true } |
| ); |
| |
| if (!user) throw new GraphQLError('User not found'); |
| return user; |
| }, |
|
|
| adminResolveReport: async (parent, { reportId, action, adminNote }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| const report = await Report.findById(reportId); |
| if (!report) throw new GraphQLError('Report not found'); |
| |
| report.status = 'resolved'; |
| report.resolvedBy = context.user.userId; |
| report.resolvedAt = new Date(); |
| report.adminNotes = adminNote || ''; |
| await report.save(); |
| |
| return report; |
| }, |
|
|
| adminResolveMessageReport: async (parent, { reportId, action, adminNote }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| const report = await MessageReport.findById(reportId); |
| if (!report) throw new GraphQLError('Report not found'); |
| |
| report.status = 'resolved'; |
| report.resolvedAt = new Date(); |
| report.resolvedBy = context.user.userId; |
| report.adminNote = adminNote || ''; |
| report.resolution = action; |
| await report.save(); |
| |
| if (action === 'delete') { |
| await Message.findByIdAndDelete(report.messageId); |
| } else if (action === 'hide') { |
| await Message.findByIdAndUpdate(report.messageId, { hidden: true }); |
| } |
| |
| return true; |
| }, |
|
|
| adminCreatePlatformBank: async (parent, { input }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| const bank = new PlatformBank({ |
| bankName: input.bankName, |
| accountName: input.accountName, |
| accountNumber: input.accountNumber, |
| iban: input.iban || '', |
| swiftCode: input.swiftCode || '', |
| isActive: input.isActive !== false, |
| order: input.order || 0 |
| }); |
| |
| await bank.save(); |
| return bank; |
| }, |
|
|
| adminUpdatePlatformBank: async (parent, { id, input }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| |
| const bank = await PlatformBank.findById(id); |
| if (!bank) throw new GraphQLError('Bank not found'); |
| |
| if (input.bankName) bank.bankName = input.bankName; |
| if (input.accountName) bank.accountName = input.accountName; |
| if (input.accountNumber) bank.accountNumber = input.accountNumber; |
| if (input.iban !== undefined) bank.iban = input.iban; |
| if (input.swiftCode !== undefined) bank.swiftCode = input.swiftCode; |
| if (input.isActive !== undefined) bank.isActive = input.isActive; |
| if (input.order !== undefined) bank.order = input.order; |
| |
| bank.updatedAt = Date.now(); |
| await bank.save(); |
| return bank; |
| }, |
|
|
| adminDeletePlatformBank: async (parent, { id }, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| await PlatformBank.findByIdAndDelete(id); |
| return true; |
| }, |
|
|
| adminRegenerateSitemaps: async (parent, args, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| return true; |
| }, |
|
|
| adminExportUsers: async (parent, args, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| return 'CSV data'; |
| }, |
|
|
| adminExportOrders: async (parent, args, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| return 'CSV data'; |
| }, |
|
|
| adminCreateBackup: async (parent, args, context) => { |
| if (!context.isAdmin) throw new GraphQLError('Admin access required'); |
| return 'Backup created'; |
| }, |
|
|
| |
| reportContent: async (parent, { targetType, targetId, reason, details }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const existingReport = await Report.findOne({ |
| reporterId: context.user.userId, |
| targetType, |
| targetId, |
| status: 'pending' |
| }); |
| |
| if (existingReport) { |
| throw new GraphQLError('You already reported this content'); |
| } |
| |
| const report = new Report({ |
| reporterId: context.user.userId, |
| targetType, |
| targetId, |
| reason, |
| details: details || '', |
| status: 'pending' |
| }); |
| |
| await report.save(); |
| return report; |
| }, |
|
|
| reportMessage: async (parent, { messageId, reason }, context) => { |
| if (!context.user) throw new GraphQLError('Not authenticated'); |
| |
| const message = await Message.findById(messageId).populate('senderId'); |
| if (!message) throw new GraphQLError('Message not found'); |
| if (message.senderId._id.toString() === context.user.userId) { |
| throw new GraphQLError('You cannot report your own message'); |
| } |
| |
| const existingReport = await MessageReport.findOne({ |
| messageId, |
| reporterId: context.user.userId |
| }); |
| |
| if (existingReport) { |
| throw new GraphQLError('You already reported this message'); |
| } |
| |
| const report = new MessageReport({ |
| messageId, |
| reporterId: context.user.userId, |
| reporterUsername: context.user.username, |
| reportedUserId: message.senderId._id, |
| reason, |
| messageContent: message.text, |
| messageAttachments: message.attachments, |
| conversationId: message.conversationId, |
| status: 'pending', |
| reportedAt: new Date() |
| }); |
| |
| await report.save(); |
| return true; |
| } |
| }; |
|
|
|
|
| |
| |
| |
|
|
| |
| async function getProfileInteractions(userId, page = 1, limit = 4) { |
| if (!userId) return []; |
| |
| const skip = (page - 1) * limit; |
| |
| try { |
| const comments = await Comment.find({ userId }) |
| .populate('projectId', 'title image userId') |
| .sort({ timestamp: -1 }) |
| .skip(skip) |
| .limit(limit) |
| .lean(); |
| |
| if (!comments || comments.length === 0) return []; |
| |
| return comments.map(c => { |
| const project = c.projectId || {}; |
| |
| |
| const projectId = project._id ? project._id.toString() : null; |
| |
| return { |
| _id: c._id || 'unknown', |
| commentId: c._id || 'unknown', |
| projectId: projectId, |
| projectTitle: project.title || null, |
| projectNickname: project.userId?.profile?.nickname || null, |
| projectImage: project.image || null, |
| rating: c.rating || 0, |
| text: c.text || '', |
| timestamp: c.timestamp || c.createdAt || new Date(), |
| replies: [], |
| likes: c.likes || [], |
| isEdited: c.isEdited || false, |
| parentCommentId: c.parentCommentId || null, |
| visibility: c.visibility || 'public' |
| }; |
| }); |
| } catch (error) { |
| console.error('Error in getProfileInteractions:', error); |
| return []; |
| } |
| } |
|
|
| |
| async function getContributionGraph(userId, year) { |
| if (!userId) { |
| return { total: 0, weeks: [], year: year || new Date().getFullYear(), months: [], weekDays: [] }; |
| } |
| |
| const targetYear = year || new Date().getFullYear(); |
| const startDate = new Date(targetYear, 0, 1); |
| const endDate = new Date(targetYear, 11, 31); |
| |
| try { |
| |
| const [posts, comments, likes, shares, jobApplications] = await Promise.all([ |
| Post.find({ userId, createdAt: { $gte: startDate, $lte: endDate } }) |
| .select('_id createdAt') |
| .lean(), |
| Comment.find({ userId, timestamp: { $gte: startDate, $lte: endDate } }) |
| .select('_id projectId timestamp') |
| .lean(), |
| Post.aggregate([ |
| { $match: { 'likes.userId': userId, createdAt: { $gte: startDate, $lte: endDate } } }, |
| { $unwind: '$likes' }, |
| { $match: { 'likes.userId': userId } }, |
| { $project: { postId: '$_id', createdAt: '$likes.likedAt' } } |
| ]), |
| Post.aggregate([ |
| { $match: { 'shares.userId': userId, createdAt: { $gte: startDate, $lte: endDate } } }, |
| { $unwind: '$shares' }, |
| { $match: { 'shares.userId': userId } }, |
| { $project: { postId: '$_id', createdAt: '$shares.sharedAt' } } |
| ]), |
| JobApplication.find({ applicantId: userId, appliedAt: { $gte: startDate, $lte: endDate } }) |
| .select('_id jobId appliedAt') |
| .lean() |
| ]); |
| |
| |
| const activitiesMap = new Map(); |
| |
| const addActivity = (date, type, id, extra = {}) => { |
| if (!date) return; |
| const dateStr = date.toISOString().split('T')[0]; |
| if (!activitiesMap.has(dateStr)) { |
| activitiesMap.set(dateStr, { count: 0, activities: [] }); |
| } |
| const entry = activitiesMap.get(dateStr); |
| entry.count++; |
| entry.activities.push({ type, id, ...extra }); |
| }; |
| |
| posts.forEach(p => addActivity(p.createdAt, 'post', p._id)); |
| comments.forEach(c => addActivity(c.timestamp, 'comment', c._id, { projectId: c.projectId })); |
| likes.forEach(l => addActivity(l.createdAt, 'like', l.postId, { postId: l.postId })); |
| shares.forEach(s => addActivity(s.createdAt, 'share', s.postId, { postId: s.postId })); |
| jobApplications.forEach(j => addActivity(j.appliedAt, 'job_application', j._id, { jobId: j.jobId })); |
| |
| |
| const weeks = []; |
| let currentDate = new Date(startDate); |
| currentDate.setDate(currentDate.getDate() - currentDate.getDay()); |
| |
| while (currentDate <= endDate) { |
| const week = []; |
| for (let i = 0; i < 7; i++) { |
| const date = new Date(currentDate); |
| const dateStr = date.toISOString().split('T')[0]; |
| const data = activitiesMap.get(dateStr) || { count: 0, activities: [] }; |
| let level = 0; |
| if (data.count > 0) level = 1; |
| if (data.count > 3) level = 2; |
| if (data.count > 7) level = 3; |
| if (data.count > 15) level = 4; |
| week.push({ |
| date: dateStr, |
| count: data.count, |
| level, |
| activities: data.activities || [] |
| }); |
| currentDate.setDate(currentDate.getDate() + 1); |
| } |
| weeks.push(week); |
| } |
| |
| let total = 0; |
| for (const [_, data] of activitiesMap) { |
| total += data.count; |
| } |
| |
| const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; |
| const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; |
| |
| return { total, weeks, year: targetYear, months, weekDays }; |
| } catch (error) { |
| console.error('Error in getContributionGraph:', error); |
| return { total: 0, weeks: [], year: targetYear, months: [], weekDays: [] }; |
| } |
| } |
|
|
| |
| async function getAlsoViewed(userId) { |
| if (!userId) return []; |
| |
| try { |
| const views = await ProfileView.find({ targetUserId: userId }) |
| .sort({ viewedAt: -1 }) |
| .limit(10) |
| .lean(); |
| |
| if (!views || views.length === 0) return []; |
| |
| const viewerIds = views.map(v => v.viewerId).filter(id => id); |
| if (viewerIds.length === 0) return []; |
| |
| const users = await User.find({ _id: { $in: viewerIds } }) |
| .limit(5) |
| .lean(); |
| |
| return users.map(u => ({ |
| _id: u._id, |
| username: u.username, |
| profile: u.profile || {}, |
| followersCount: u.followersCount || 0 |
| })); |
| } catch (error) { |
| console.error('Error in getAlsoViewed:', error); |
| return []; |
| } |
| } |
|
|
| |
| async function getPageSuggestions(limit = 2) { |
| try { |
| const pages = await Page.find({ isEnabled: true }) |
| .sort({ followersCount: -1 }) |
| .limit(limit) |
| .lean(); |
| |
| if (!pages || pages.length === 0) return []; |
| |
| return pages.map(p => ({ |
| _id: p._id, |
| name: p.title || p.slug || 'Unknown', |
| logo: p.thumbnail || null, |
| followersCount: p.followersCount || 0, |
| isFollowing: false, |
| url: `/page/${p.slug || p._id}` |
| })); |
| } catch (error) { |
| console.error('Error in getPageSuggestions:', error); |
| return []; |
| } |
| } |
|
|
| |
| async function getProfileAnalytics(userId) { |
| if (!userId) { |
| return { |
| views: 0, |
| uniqueViews: 0, |
| weeklyViews: 0, |
| impressions: 0, |
| searches: 0, |
| lastUpdated: new Date() |
| }; |
| } |
| |
| try { |
| const analytics = await ProfileAnalytics.findOne({ userId }); |
| |
| const weekAgo = new Date(); |
| weekAgo.setDate(weekAgo.getDate() - 7); |
| |
| const weeklyViews = await ProfileView.countDocuments({ |
| targetUserId: userId, |
| viewedAt: { $gte: weekAgo } |
| }); |
| |
| return { |
| views: analytics?.views || 0, |
| uniqueViews: analytics?.uniqueViews || 0, |
| weeklyViews: weeklyViews || 0, |
| impressions: analytics?.impressions || 0, |
| searches: analytics?.searches || 0, |
| lastUpdated: analytics?.updatedAt || new Date() |
| }; |
| } catch (error) { |
| console.error('Error in getProfileAnalytics:', error); |
| return { |
| views: 0, |
| uniqueViews: 0, |
| weeklyViews: 0, |
| impressions: 0, |
| searches: 0, |
| lastUpdated: new Date() |
| }; |
| } |
| } |
|
|
| |
| async function getSubscriptionStatus(userId) { |
| if (!userId) { |
| return { |
| hasActiveSubscription: false, |
| canCreateStore: false, |
| message: 'No user ID provided', |
| planName: null, |
| daysLeft: 0 |
| }; |
| } |
| |
| try { |
| const subscription = await UserSubscription.findOne({ |
| userId, |
| status: 'active' |
| }).populate('planId'); |
| |
| if (subscription) { |
| const now = new Date(); |
| const endDate = new Date(subscription.endDate); |
| const daysLeft = Math.ceil((endDate - now) / (1000 * 60 * 60 * 24)); |
| |
| |
| const canCreateStore = !!(subscription.planId?.features?.storeEnabled); |
| |
| return { |
| hasActiveSubscription: true, |
| canCreateStore: canCreateStore, |
| message: 'Active subscription', |
| planName: subscription.planId?.name || 'Pro', |
| daysLeft: daysLeft > 0 ? daysLeft : 0 |
| }; |
| } |
| |
| return { |
| hasActiveSubscription: false, |
| canCreateStore: false, |
| message: 'No active subscription', |
| planName: null, |
| daysLeft: 0 |
| }; |
| } catch (error) { |
| console.error('Error in getSubscriptionStatus:', error); |
| return { |
| hasActiveSubscription: false, |
| canCreateStore: false, |
| message: 'Error checking subscription', |
| planName: null, |
| daysLeft: 0 |
| }; |
| } |
| } |
| |
| |
| |
|
|
| module.exports = { |
| Query, |
| Mutation |
| }; |