Project Scope: Small project with focus on UX features over security Developer Role: Dev A (User, Profile, Social, Network, Search, Project domains) Total Estimated Time: 80-105 hours across 7 phases (5 base + 2 enhancement phases) Last Updated: December 13, 2025
Estimated Time: 4-6 hours Status: ✅ Done
Files Modified:
src/models/user/accountModel.js- AddedverificationTokenExpiresfieldsrc/services/accountService.js- Set 10-minute expiry, validate in verifyAccount
Implementation Details:
// Account model now has:
verificationTokenExpires: { type: Date }
// OTP expires after 10 minutes
const getOTPExpiry = () => {
return new Date(Date.now() + OTP_EXPIRY_MINUTES * 60 * 1000)
}
// Validation in verifyAccount:
if (account.verificationTokenExpires && new Date() > account.verificationTokenExpires) {
throw new Error('OTP has expired. Please request a new verification code.')
}Files Modified:
src/services/accountService.js- AddedcompletePasswordReset(token, newPassword)src/validations/accountValidation.js- AddedcompletePasswordResetSchemasrc/controllers/accountController.js- AddedcompletePasswordResetcontrollersrc/routes/v1/authRoute.js- AddedPATCH /password-resetsroute
API Endpoint:
PATCH /api/v1/password-resets
Body: { token: string, newPassword: string (min 8 chars) }
Response: { message: "Password has been reset successfully" }
Flow:
- User clicks reset link in email → Frontend extracts token from URL
- Frontend calls
PATCH /password-resetswith token + new password - Backend validates token hash, checks expiry, updates password
- Confirmation email sent to user
Files Created:
src/services/emailService.js- Full Brevo SDK integration
Features:
- Automatic fallback to mock emails if
BREVO_API_KEYnot configured - Beautiful HTML email templates with consistent branding
- Plain text fallbacks for email clients
Email Templates:
| Template | Function | Use Case |
|---|---|---|
| OTP Verification | sendOTPEmail() |
Registration, resend OTP |
| Password Reset | sendPasswordResetEmail() |
Forgot password |
| Welcome | sendWelcomeEmail() |
After account verification |
| Password Changed | sendPasswordChangedEmail() |
After password change |
Environment Variables Required:
BREVO_API_KEY=your-brevo-api-key
EMAIL_SENDER_NAME=NetWorkr
EMAIL_SENDER_EMAIL=noreply@networkr.com
FRONTEND_URL=http://localhost:3000To Enable Real Emails:
- Create Brevo account at https://www.brevo.com (free: 300 emails/day)
- Get API key from Settings → SMTP & API → API Keys
- Add
BREVO_API_KEYto.envfile - Restart server
Estimated Time: 6-8 hours Status: ✅ Done Dependencies: Cloudinary account (free tier available)
Files to Create:
src/config/cloudinary.js
Implementation:
import { v2 as cloudinary } from 'cloudinary'
cloudinary.config({
cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
api_key: process.env.CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET
})
export const uploadToCloudinary = async (buffer, options = {}) => {
return new Promise((resolve, reject) => {
const uploadStream = cloudinary.uploader.upload_stream(
{
folder: 'networkr',
resource_type: 'auto',
...options
},
(error, result) => {
if (error) reject(error)
else resolve(result)
}
)
streamifier.createReadStream(buffer).pipe(uploadStream)
})
}
export { cloudinary }Environment Variables:
CLOUDINARY_CLOUD_NAME=your-cloud-name
CLOUDINARY_API_KEY=your-api-key
CLOUDINARY_API_SECRET=your-api-secretFiles to Create:
src/middlewares/uploadMiddleware.js
Implementation:
import multer from 'multer'
// Memory storage for Cloudinary streaming
const storage = multer.memoryStorage()
// File filter for images
const imageFilter = (req, file, cb) => {
if (file.mimetype.startsWith('image/')) {
cb(null, true)
} else {
cb(new Error('Only image files are allowed'), false)
}
}
// File filter for images and videos
const mediaFilter = (req, file, cb) => {
if (file.mimetype.startsWith('image/') || file.mimetype.startsWith('video/')) {
cb(null, true)
} else {
cb(new Error('Only image and video files are allowed'), false)
}
}
export const uploadAvatar = multer({
storage,
fileFilter: imageFilter,
limits: { fileSize: 5 * 1024 * 1024 } // 5MB
}).single('avatar')
export const uploadBackground = multer({
storage,
fileFilter: imageFilter,
limits: { fileSize: 10 * 1024 * 1024 } // 10MB
}).single('background')
export const uploadPostMedia = multer({
storage,
fileFilter: mediaFilter,
limits: { fileSize: 50 * 1024 * 1024 } // 50MB
}).array('media', 10) // Max 10 filesFiles to Modify:
src/services/profileService.js- AdduploadAvatar(),uploadBackground()src/controllers/profileController.js- Add upload controllerssrc/routes/v1/userRoute.js- Add upload routes
API Endpoints:
POST /api/v1/users/me/avatar
- Content-Type: multipart/form-data
- Body: avatar (file, max 5MB, image only)
- Response: { avatarUrl: string }
POST /api/v1/users/me/background
- Content-Type: multipart/form-data
- Body: background (file, max 10MB, image only)
- Response: { backgroundUrl: string }
Files to Modify:
src/services/socialService.js- ModifycreatePost()for file uploadssrc/controllers/socialController.js- Handle multipart datasrc/routes/v1/socialRoute.js- Add multer middleware to POST /posts
API Changes:
POST /api/v1/posts
- Content-Type: multipart/form-data
- Body:
- content (text)
- visibility (enum)
- media (files, max 10, max 50MB each)
- Response: { post with media array }
Estimated Time: 8-12 hours Status: 🔄 Not Started Dependencies: None (Socket.io already installed)
Files to Create:
src/config/socket.js
Files to Modify:
src/server.js- Initialize Socket.io with HTTP server
Implementation:
// src/config/socket.js
import { Server } from 'socket.io'
import jwt from 'jsonwebtoken'
let io = null
export const initSocket = (httpServer) => {
io = new Server(httpServer, {
cors: {
origin: process.env.CORS_WHITELIST?.split(',') || '*',
methods: ['GET', 'POST']
}
})
// Authentication middleware
io.use((socket, next) => {
const token = socket.handshake.auth.token
if (!token) return next(new Error('Authentication required'))
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET)
socket.userId = decoded.personId
socket.accountId = decoded.accountId
next()
} catch (err) {
next(new Error('Invalid token'))
}
})
io.on('connection', (socket) => {
// Join user's personal room for notifications
socket.join(`user:${socket.userId}`)
console.log(`User ${socket.userId} connected`)
socket.on('disconnect', () => {
console.log(`User ${socket.userId} disconnected`)
})
})
return io
}
export const getIO = () => {
if (!io) throw new Error('Socket.io not initialized')
return io
}
export const emitToUser = (userId, event, data) => {
if (io) io.to(`user:${userId}`).emit(event, data)
}Server.js Changes:
import http from 'http'
import { initSocket } from './config/socket.js'
const httpServer = http.createServer(app)
initSocket(httpServer)
httpServer.listen(PORT, () => {
console.log(`Server running on port ${PORT}`)
})Files to Create:
src/services/notificationService.jssrc/controllers/notificationController.jssrc/routes/v1/notificationRoute.js
Notification Model (already exists at src/models/communication/notificationModel.js):
// Types: LIKE, COMMENT, SHARE, FOLLOW, JOB_APPLICATION, MESSAGE
// Fields: recipientId, senderId, type, referenceId, referenceType, message, isReadService Functions:
// Create and emit notification
const createNotification = async (recipientId, senderId, type, referenceId, referenceType, message) => {
const notification = await Notification.create({...})
// Real-time emit
emitToUser(recipientId, 'notification', notification)
return notification
}
// Get notifications with pagination
const getNotifications = async (userId, page = 1, limit = 20) => {...}
// Mark single as read
const markAsRead = async (notificationId, userId) => {...}
// Mark all as read
const markAllAsRead = async (userId) => {...}
// Get unread count
const getUnreadCount = async (userId) => {...}API Endpoints:
GET /api/v1/notifications
- Query: page, limit
- Response: { notifications[], totalCount, unreadCount }
PATCH /api/v1/notifications/:id/read
- Response: { notification with isRead: true }
PATCH /api/v1/notifications/read-all
- Response: { message: "All notifications marked as read" }
GET /api/v1/notifications/unread-count
- Response: { count: number }
Files to Modify:
src/services/socialService.js
Trigger Points:
handleInteraction()→ Create LIKE/SHARE notification to post owneraddComment()→ Create COMMENT notification to post ownerhandleConnection()→ Create FOLLOW notification (when implemented)
Files to Modify:
src/services/profileService.js- EnhancecalculateProfileScore()src/controllers/profileController.js- Add endpointsrc/routes/v1/userRoute.js- Add route
API Endpoint:
GET /api/v1/users/me/completeness
Response: {
score: 75,
level: "Intermediate", // Beginner (0-33), Intermediate (34-66), Advanced (67-100)
sections: {
basicInfo: { score: 100, max: 20, completed: ["firstName", "lastName", "email"] },
professionalInfo: { score: 50, max: 25, completed: ["headline"], missing: ["summary", "currentCompany"] },
experience: { score: 80, max: 25, count: 2, suggestion: "Add more recent experience" },
skills: { score: 60, max: 15, count: 3, suggestion: "Add at least 5 skills" },
education: { score: 0, max: 10, count: 0, suggestion: "Add your education" },
photo: { score: 100, max: 5, hasPhoto: true }
},
tips: [
"Add a professional summary to increase visibility",
"Add your education history",
"Consider adding 2 more skills"
]
}
Estimated Time: 12-16 hours Status: 🔄 Not Started
Files to Modify:
src/services/socialService.js- Replace chronologicalviewFeed()withviewPersonalizedFeed()
Algorithm Factors:
- Engagement Score:
likes * 1 + comments * 2 + shares * 3 - Connection Weight: Posts from connections score higher
- Recency Decay:
score * Math.exp(-hoursOld / 72)(72-hour half-life) - Content Match: Skills/industry overlap (optional, advanced)
Implementation:
const viewPersonalizedFeed = async (userId, page = 1, limit = 20) => {
// Get user's connections
const connections = await Connection.find({
$or: [{ fromUserId: userId }, { toUserId: userId }],
status: 'accepted'
})
const connectionIds = connections.map(c =>
c.fromUserId.equals(userId) ? c.toUserId : c.fromUserId
)
// Aggregate with scoring
const posts = await Post.aggregate([
{ $match: { _destroy: false, visibility: { $ne: 'PRIVATE' } } },
{
$addFields: {
engagementScore: {
$add: [
{ $ifNull: ['$likeCount', 0] },
{ $multiply: [{ $ifNull: ['$commentCount', 0] }, 2] },
{ $multiply: [{ $ifNull: ['$shareCount', 0] }, 3] }
]
},
isConnection: { $in: ['$authorId', connectionIds] },
hoursOld: {
$divide: [
{ $subtract: [new Date(), '$createdAt'] },
3600000
]
}
}
},
{
$addFields: {
finalScore: {
$multiply: [
{ $add: ['$engagementScore', { $cond: ['$isConnection', 50, 0] }] },
{ $exp: { $divide: [{ $multiply: ['$hoursOld', -1] }, 72] } }
]
}
}
},
{ $sort: { finalScore: -1 } },
{ $skip: (page - 1) * limit },
{ $limit: limit }
])
return posts
}Files to Modify:
src/services/profileService.js- EnhancesearchUsers()src/validations/profileValidation.js- Add filter validationsrc/routes/v1/userRoute.js- Update Swagger docs
New Query Parameters:
GET /api/v1/users
Query:
- q: string (name search, existing)
- skills: string[] (filter by skills)
- location: string (city/country filter)
- industry: string (industry filter)
- company: string (current/past company)
- experienceLevel: enum (Entry, Mid, Senior, Executive)
- page, limit (pagination)
Implementation (MongoDB Aggregation):
const searchUsers = async (filters, page = 1, limit = 20) => {
const pipeline = []
// Match base criteria
pipeline.push({ $match: { _destroy: false } })
// Name search
if (filters.q) {
pipeline.push({
$match: {
$or: [
{ firstName: { $regex: filters.q, $options: 'i' } },
{ lastName: { $regex: filters.q, $options: 'i' } }
]
}
})
}
// Lookup skills
if (filters.skills?.length) {
pipeline.push(
{ $lookup: { from: 'skills', localField: '_id', foreignField: 'personId', as: 'skillDocs' } },
{ $match: { 'skillDocs.name': { $in: filters.skills } } }
)
}
// Lookup experiences for company/industry
if (filters.company || filters.industry) {
pipeline.push(
{ $lookup: { from: 'experiences', localField: '_id', foreignField: 'personId', as: 'experienceDocs' } }
)
if (filters.company) {
pipeline.push({ $match: { 'experienceDocs.companyName': { $regex: filters.company, $options: 'i' } } })
}
if (filters.industry) {
pipeline.push({ $match: { 'experienceDocs.industry': filters.industry } })
}
}
// Location filter
if (filters.location) {
pipeline.push({
$match: {
$or: [
{ 'contactInfo.city': { $regex: filters.location, $options: 'i' } },
{ 'contactInfo.country': { $regex: filters.location, $options: 'i' } }
]
}
})
}
// Pagination
pipeline.push({ $skip: (page - 1) * limit }, { $limit: limit })
return Person.aggregate(pipeline)
}Files to Modify:
src/models/social/commentModel.js- Adddepthfield (max 3)src/services/socialService.js- AddgetThreadedComments()
Model Update:
// Add to commentSchema:
depth: {
type: Number,
default: 0,
max: 3
}Implementation:
const getThreadedComments = async (postId, page = 1, limit = 20) => {
// Get top-level comments with replies
const comments = await Comment.aggregate([
{ $match: { postId: ObjectId(postId), parentCommentId: null, _destroy: false } },
{ $sort: { createdAt: -1 } },
{ $skip: (page - 1) * limit },
{ $limit: limit },
// Lookup nested replies using $graphLookup
{
$graphLookup: {
from: 'comments',
startWith: '$_id',
connectFromField: '_id',
connectToField: 'parentCommentId',
as: 'replies',
maxDepth: 2, // depth 0 + 2 more levels = max 3
depthField: 'replyDepth',
restrictSearchWithMatch: { _destroy: false }
}
},
// Populate author info
{ $lookup: { from: 'persons', localField: 'authorId', foreignField: '_id', as: 'author' } },
{ $unwind: '$author' }
])
// Build nested tree structure
return comments.map(comment => buildCommentTree(comment))
}
const buildCommentTree = (comment) => {
const replies = comment.replies || []
const directReplies = replies
.filter(r => r.parentCommentId?.equals(comment._id))
.map(r => ({
...r,
replies: replies.filter(rr => rr.parentCommentId?.equals(r._id))
}))
return { ...comment, replies: directReplies }
}Estimated Time: 12-16 hours Status: 🔄 Not Started Dependencies: PDFKit or Puppeteer, OpenAI API (optional)
Packages to Install:
yarn add pdfkit
# OR for HTML-to-PDF with better styling:
yarn add puppeteerFiles to Create:
src/services/cvService.jssrc/templates/cv/(HTML templates if using Puppeteer)
Files to Modify:
src/controllers/profileController.jssrc/routes/v1/userRoute.js
API Endpoint:
GET /api/v1/users/me/cv/download
Query: template=modern|professional|minimal (default: modern)
Response: PDF file stream (Content-Type: application/pdf)
PDFKit Implementation:
import PDFDocument from 'pdfkit'
const generateCVPdf = async (personId, template = 'modern') => {
// Fetch all profile data
const person = await Person.findById(personId)
const experiences = await Experience.find({ personId, _destroy: false })
const skills = await Skill.find({ personId, _destroy: false })
const education = await Education.find({ personId, _destroy: false })
const doc = new PDFDocument({ size: 'A4', margin: 50 })
// Header with name and contact
doc.fontSize(24).font('Helvetica-Bold').text(`${person.firstName} ${person.lastName}`)
doc.fontSize(12).font('Helvetica').text(person.headline || '')
doc.text(person.contactInfo?.email || '')
doc.text(person.contactInfo?.phone || '')
doc.moveDown()
// Summary
if (person.summary) {
doc.fontSize(14).font('Helvetica-Bold').text('Professional Summary')
doc.fontSize(11).font('Helvetica').text(person.summary)
doc.moveDown()
}
// Experience
if (experiences.length) {
doc.fontSize(14).font('Helvetica-Bold').text('Experience')
experiences.forEach(exp => {
doc.fontSize(12).font('Helvetica-Bold').text(exp.title)
doc.fontSize(11).font('Helvetica').text(`${exp.companyName} | ${exp.startDate} - ${exp.endDate || 'Present'}`)
if (exp.description) doc.text(exp.description)
doc.moveDown(0.5)
})
}
// Skills
if (skills.length) {
doc.fontSize(14).font('Helvetica-Bold').text('Skills')
doc.fontSize(11).font('Helvetica').text(skills.map(s => s.name).join(' • '))
doc.moveDown()
}
// Education
if (education.length) {
doc.fontSize(14).font('Helvetica-Bold').text('Education')
education.forEach(edu => {
doc.fontSize(12).font('Helvetica-Bold').text(edu.degree)
doc.fontSize(11).font('Helvetica').text(`${edu.institution} | ${edu.graduationYear}`)
doc.moveDown(0.5)
})
}
return doc
}
// Controller:
const downloadCV = async (req, res) => {
const doc = await cvService.generateCVPdf(req.user.personId, req.query.template)
res.setHeader('Content-Type', 'application/pdf')
res.setHeader('Content-Disposition', `attachment; filename=CV-${req.user.personId}.pdf`)
doc.pipe(res)
doc.end()
}Packages to Install:
yarn add openaiFiles to Create:
src/services/aiService.js
Environment Variables:
OPENAI_API_KEY=sk-your-api-keyImplementation:
import OpenAI from 'openai'
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
const enhanceSummary = async (currentSummary, experiences, skills) => {
const prompt = `
Enhance this professional summary for a LinkedIn-style profile:
Current Summary: ${currentSummary || 'None provided'}
Experience: ${experiences.map(e => `${e.title} at ${e.companyName}`).join(', ')}
Skills: ${skills.map(s => s.name).join(', ')}
Write a compelling 2-3 sentence professional summary that:
- Highlights key achievements
- Mentions top skills
- Is written in first person
- Is professional but personable
`
const completion = await openai.chat.completions.create({
model: 'gpt-3.5-turbo',
messages: [{ role: 'user', content: prompt }],
max_tokens: 200
})
return completion.choices[0].message.content
}
const suggestSkills = async (currentSkills, experiences, industry) => {
const prompt = `
Based on this professional profile, suggest 5 additional skills they should add:
Current Skills: ${currentSkills.map(s => s.name).join(', ')}
Experience: ${experiences.map(e => `${e.title} at ${e.companyName} (${e.industry})`).join(', ')}
Industry: ${industry || 'Not specified'}
Return only a JSON array of skill names: ["skill1", "skill2", ...]
`
const completion = await openai.chat.completions.create({
model: 'gpt-3.5-turbo',
messages: [{ role: 'user', content: prompt }],
max_tokens: 100
})
return JSON.parse(completion.choices[0].message.content)
}API Endpoints:
POST /api/v1/users/me/enhance/summary
Response: { enhancedSummary: string, originalSummary: string }
GET /api/v1/users/me/suggestions/skills
Response: { suggestedSkills: string[] }
Cost Management:
- Rate limit: 5 requests per user per day
- Implement request caching (24-hour TTL)
- Monitor usage via OpenAI dashboard
- Fallback to rule-based suggestions if API fails
@getbrevo/brevo- Email servicecloudinary- File storagemulter- File upload middlewarestreamifier- Buffer to stream conversionsocket.io- Real-time communications
# For CV generation (choose one):
yarn add pdfkit
# OR
yarn add puppeteer
# For AI features (optional):
yarn add openai| Service | Purpose | Free Tier |
|---|---|---|
| Brevo | Email sending | 300 emails/day |
| Cloudinary | File storage | 25GB storage, 25GB bandwidth |
| OpenAI | AI features | $5 credit (new accounts) |
# Server
PORT=8017
BUILD_MODE=dev
JWT_SECRET=your-super-secret-key
JWT_EXPIRES_IN=7d
FRONTEND_URL=http://localhost:3000
# MongoDB
MONGODB_URI=mongodb://localhost:27017/networkr
# Email (Brevo)
BREVO_API_KEY=your-brevo-api-key
EMAIL_SENDER_NAME=NetWorkr
EMAIL_SENDER_EMAIL=noreply@networkr.com
# File Storage (Cloudinary)
CLOUDINARY_CLOUD_NAME=your-cloud-name
CLOUDINARY_API_KEY=your-api-key
CLOUDINARY_API_SECRET=your-api-secret
# AI (OpenAI) - Optional
OPENAI_API_KEY=sk-your-api-key
# CORS
CORS_WHITELIST=http://localhost:3000,http://localhost:5173| Method | Endpoint | Description |
|---|---|---|
| POST | /registrations |
Register (OTP now expires in 10 min) |
| POST | /verifications |
Verify OTP (checks expiry) |
| POST | /verifications/resend |
Resend OTP (sets new expiry) |
| POST | /password-resets |
Request password reset |
| PATCH | /password-resets |
NEW Complete reset with token |
| PATCH | /sessions/password |
Change password (authenticated) |
| Method | Endpoint | Description |
|---|---|---|
| POST | /users/me/avatar |
Upload profile picture |
| POST | /users/me/background |
Upload background image |
| POST | /posts |
Create post with media |
| Method | Endpoint | Description |
|---|---|---|
| GET | /notifications |
Get notifications |
| PATCH | /notifications/:id/read |
Mark as read |
| PATCH | /notifications/read-all |
Mark all as read |
| GET | /notifications/unread-count |
Get unread count |
| GET | /users/me/completeness |
Profile completeness |
| Method | Endpoint | Description |
|---|---|---|
| GET | /feeds/main |
Personalized feed (algorithm) |
| GET | /users |
Enhanced search with filters |
| Method | Endpoint | Description |
|---|---|---|
| GET | /users/me/cv/download |
Download CV as PDF |
| POST | /users/me/enhance/summary |
AI-enhanced summary |
| GET | /users/me/suggestions/skills |
AI skill suggestions |
Estimated Time: 20-25 hours Status: 🔄 In Progress Dependencies: Cloudinary account, coordination with Dev B for notifications
Dev A Responsibility: User, Profile, Social, Network, Search, Project domains Dev B Responsibility: Jobs, Company, Applications, Admin (not covered here)
✅ Completed (Weeks 1-4):
- Week 1: AccountController (Auth with OTP expiry, password reset, email service)
- Week 1-2: ProfileController (complete profile CRUD)
- Week 2: SocialController (posts, comments, interactions)
- Week 3: NetworkController (connections, invitations)
- Week 3-4: SearchController (members, jobs, companies, candidate filtering)
- Week 4: ProjectController (collaboration invitations)
🚨 Critical Gaps in Dev A Domain:
- No media upload capability (profile photos, post media)
- Notification triggers not implemented (3 TODOs in code)
- No profile completeness guidance
- Feed is purely chronological (no engagement algorithm)
- Comment threading not implemented
Estimated Time: 8-10 hours Priority: 🔴 Critical Status: 🔄 Not Started
Current Issue: Users cannot upload actual files, must use external URLs
Files to Create:
src/config/cloudinary.jssrc/middlewares/uploadMiddleware.js
Files to Modify:
src/services/profileService.js- AdduploadAvatar(),uploadBackground()src/controllers/profileController.js- Add upload handlerssrc/routes/v1/userRoute.js- Add upload routes with Swagger docssrc/services/socialService.js- EnhancecreatePost()for file uploadssrc/controllers/socialController.js- Handle multipart/form-datasrc/routes/v1/socialRoute.js- Update POST /posts route
Implementation - Cloudinary Config:
// src/config/cloudinary.js
import { v2 as cloudinary } from 'cloudinary'
import streamifier from 'streamifier'
cloudinary.config({
cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
api_key: process.env.CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET
})
export const uploadToCloudinary = async (fileBuffer, folder, options = {}) => {
return new Promise((resolve, reject) => {
const uploadStream = cloudinary.uploader.upload_stream({
folder: `networkr/${folder}`,
resource_type: 'auto',
transformation: options.transformation,
...options
}, (error, result) => {
if (error) reject(error)
else resolve(result.secure_url)
})
streamifier.createReadStream(fileBuffer).pipe(uploadStream)
})
}
export { cloudinary }Implementation - Upload Middleware:
// src/middlewares/uploadMiddleware.js
import multer from 'multer'
const storage = multer.memoryStorage()
const imageFilter = (req, file, cb) => {
if (file.mimetype.startsWith('image/')) {
cb(null, true)
} else {
cb(new Error('Only image files are allowed'), false)
}
}
const mediaFilter = (req, file, cb) => {
if (file.mimetype.startsWith('image/') || file.mimetype.startsWith('video/')) {
cb(null, true)
} else {
cb(new Error('Only image and video files allowed'), false)
}
}
export const uploadAvatar = multer({
storage,
fileFilter: imageFilter,
limits: { fileSize: 5 * 1024 * 1024 } // 5MB
}).single('avatar')
export const uploadBackground = multer({
storage,
fileFilter: imageFilter,
limits: { fileSize: 10 * 1024 * 1024 } // 10MB
}).single('background')
export const uploadPostMedia = multer({
storage,
fileFilter: mediaFilter,
limits: { fileSize: 50 * 1024 * 1024 } // 50MB per file
}).array('media', 10) // Max 10 filesNew API Endpoints:
POST /api/v1/users/me/avatar
- Content-Type: multipart/form-data
- Body: avatar (image file, max 5MB)
- Cloudinary transformation: width 400, height 400, crop: fill
- Response: { avatarUrl: string }
POST /api/v1/users/me/background
- Content-Type: multipart/form-data
- Body: background (image file, max 10MB)
- Cloudinary transformation: width 1200, height 400, crop: fill
- Response: { backgroundUrl: string }
POST /api/v1/posts (updated)
- Content-Type: multipart/form-data
- Body:
- content: string
- visibility: enum
- media: file[] (max 10 files, 50MB each)
- Response: { post with media array }
Environment Variables Required:
CLOUDINARY_CLOUD_NAME=your-cloud-name
CLOUDINARY_API_KEY=your-api-key
CLOUDINARY_API_SECRET=your-api-secretEstimated Time: 4-5 hours Priority: 🟡 Important Status: 🔄 Not Started
Current Issue: No guidance for users to complete profiles
Files to Modify:
src/services/profileService.js- AddgetProfileCompleteness(personId)src/controllers/profileController.js- Add controllersrc/routes/v1/userRoute.js- Add route with Swagger
Implementation:
// src/services/profileService.js
const getProfileCompleteness = async (personId) => {
const person = await Person.findById(personId)
const experiences = await Experience.find({ personId, _destroy: false })
const skills = await Skill.find({ personId, _destroy: false })
const educations = await Education.find({ personId, _destroy: false })
const projects = await Project.find({ personId, _destroy: false })
// Scoring weights (total 100 points)
const weights = {
basicInfo: 20, // firstName, lastName, email
photo: 10, // avatar
headline: 10, // headline/title
summary: 15, // professional summary
experience: 20, // work experience (min 2)
skills: 15, // skills (min 5)
education: 10 // education (min 1)
}
const sections = {
basicInfo: {
max: weights.basicInfo,
score: person.firstName && person.lastName && person.email ? weights.basicInfo : 0,
status: person.firstName && person.lastName && person.email ? 'complete' : 'incomplete',
completed: [person.firstName && 'firstName', person.lastName && 'lastName', person.email && 'email'].filter(Boolean)
},
photo: {
max: weights.photo,
score: person.avatar ? weights.photo : 0,
hasAvatar: !!person.avatar,
status: person.avatar ? 'complete' : 'missing'
},
headline: {
max: weights.headline,
score: person.headline ? weights.headline : 0,
hasHeadline: !!person.headline,
status: person.headline ? 'complete' : 'missing'
},
summary: {
max: weights.summary,
score: person.summary && person.summary.length >= 50 ? weights.summary : 0,
hasSummary: !!person.summary,
status: person.summary && person.summary.length >= 50 ? 'complete' : 'missing',
currentLength: person.summary?.length || 0,
minLength: 50
},
experience: {
max: weights.experience,
count: experiences.length,
min: 2,
score: Math.min(experiences.length / 2, 1) * weights.experience,
status: experiences.length >= 2 ? 'complete' : 'partial'
},
skills: {
max: weights.skills,
count: skills.length,
min: 5,
score: Math.min(skills.length / 5, 1) * weights.skills,
status: skills.length >= 5 ? 'complete' : 'partial'
},
education: {
max: weights.education,
count: educations.length,
min: 1,
score: educations.length >= 1 ? weights.education : 0,
status: educations.length >= 1 ? 'complete' : 'missing'
}
}
const totalScore = Object.values(sections).reduce((sum, section) => sum + section.score, 0)
const level = totalScore < 34 ? 'Beginner' : totalScore < 67 ? 'Intermediate' : 'Advanced'
const missingItems = []
if (!person.avatar) missingItems.push('Add a profile photo')
if (!person.headline) missingItems.push('Add a professional headline')
if (!person.summary || person.summary.length < 50) missingItems.push('Add a professional summary (min 50 characters)')
if (experiences.length < 2) missingItems.push(`Add ${2 - experiences.length} more work experience(s)`)
if (skills.length < 5) missingItems.push(`Add ${5 - skills.length} more skill(s)`)
if (educations.length < 1) missingItems.push('Add your education history')
return {
score: Math.round(totalScore),
level,
sections,
missingItems,
nextSteps: missingItems.slice(0, 3) // Top 3 priorities
}
}New API Endpoint:
GET /api/v1/users/me/completeness
Response: {
score: 65,
level: "Intermediate",
sections: {
basicInfo: { max: 20, score: 20, status: "complete" },
photo: { max: 10, score: 10, hasAvatar: true, status: "complete" },
headline: { max: 10, score: 10, hasHeadline: true, status: "complete" },
summary: { max: 15, score: 0, status: "missing", currentLength: 0, minLength: 50 },
experience: { max: 20, score: 10, count: 1, min: 2, status: "partial" },
skills: { max: 15, score: 9, count: 3, min: 5, status: "partial" },
education: { max: 10, score: 0, count: 0, min: 1, status: "missing" }
},
missingItems: [
"Add a professional summary (min 50 characters)",
"Add 1 more work experience(s)",
"Add 2 more skill(s)",
"Add your education history"
],
nextSteps: [
"Add a professional summary (min 50 characters)",
"Add 1 more work experience(s)",
"Add 2 more skill(s)"
]
}
Estimated Time: 8-10 hours Priority: 🟡 Important Status: ⏸️ Blocked (waiting for Dev B) Dependencies: Dev B must create notification infrastructure first
Current TODOs in Code:
// src/services/networkService.js:81
// TODO: Notification trigger - CONNECTION_REQUEST
// src/services/networkService.js:134
// TODO: Notification trigger - CONNECTION_ACCEPTED
// src/services/projectService.js:106
// TODO: Trigger email notification to invitee
// src/services/projectService.js:188
// TODO: Trigger notification to project ownerDev B Must Complete First:
- Create
src/services/notificationService.jswithcreate()method - Create
src/controllers/notificationController.js - Create
src/routes/v1/notificationRoute.js - Setup Socket.io in
src/server.js - Create
src/config/socket.js
Then Dev A Integration:
Files to Modify:
src/services/networkService.js- Add notification triggerssrc/services/projectService.js- Add notification + email triggerssrc/services/socialService.js- Add notification triggers for likes, comments, shares
Implementation Pattern:
// After Dev B creates notificationService
import { notificationService } from './notificationService.js'
// In networkService.sendInvitation():
await notificationService.create({
recipientId: targetMemberId,
senderId: currentUserId,
type: 'CONNECTION_REQUEST',
referenceId: invitation._id,
referenceType: 'ConnectionRequest',
message: `${senderName} sent you a connection request`
})
// In networkService.acceptInvitation():
await notificationService.create({
recipientId: invitation.senderId,
senderId: currentUserId,
type: 'CONNECTION_ACCEPTED',
referenceId: invitation._id,
referenceType: 'ConnectionRequest',
message: `${accepterName} accepted your connection request`
})
// In projectService.inviteCollaborator():
await emailService.sendProjectInvitation(email, projectTitle, inviterName)
await notificationService.create({
recipientId: inviteeId,
senderId: currentUserId,
type: 'PROJECT_INVITATION',
referenceId: invitation._id,
referenceType: 'ProjectInvitation',
message: `${inviterName} invited you to collaborate on ${projectTitle}`
})
// In socialService.handleInteraction() - LIKE:
if (type === 'LIKE' && post.authorId.toString() !== userId) {
await notificationService.create({
recipientId: post.authorId,
senderId: userId,
type: 'LIKE',
referenceId: postId,
referenceType: 'Post',
message: `${userName} liked your post`
})
}
// In socialService.addComment():
if (post.authorId.toString() !== userId) {
await notificationService.create({
recipientId: post.authorId,
senderId: userId,
type: 'COMMENT',
referenceId: commentId,
referenceType: 'Comment',
message: `${userName} commented on your post`
})
}Coordination: Schedule integration for Week 5 Day 4-5 after Dev B completes notification infrastructure.
Estimated Time: 17-22 hours Status: 🔄 Not Started Priority: Nice-to-have improvements
Estimated Time: 6-8 hours Priority: 🟢 Enhancement Status: 🔄 Not Started
Current Issue: Feed is purely chronological, no engagement-based ranking
Files to Modify:
src/services/socialService.js- ReplaceviewFeed()with scoring logicsrc/controllers/socialController.js- Add optional query paramalgorithm=smart|chronologicalsrc/routes/v1/socialRoute.js- Update Swagger docs
Implementation - Personalized Feed:
// src/services/socialService.js
const viewPersonalizedFeed = async (userId, page = 1, limit = 20) => {
// Get user's connections for weighting
const connectionRequests = await ConnectionRequest.find({
$or: [
{ sender: userId, status: 'accepted' },
{ recipient: userId, status: 'accepted' }
],
_destroy: false
})
const connectionIds = connectionRequests.map(cr =>
cr.sender.toString() === userId ? cr.recipient.toString() : cr.sender.toString()
)
// Aggregation pipeline with scoring
const posts = await Post.aggregate([
{
$match: {
_destroy: false,
$or: [
{ visibility: 'public' },
{ visibility: 'connections', authorId: { $in: connectionIds } }
]
}
},
// Add engagement score
{
$addFields: {
// Base engagement: likes + comments*2 + shares*3
engagementScore: {
$add: [
{ $size: { $ifNull: ['$likes', []] } },
{ $multiply: [{ $size: { $ifNull: ['$comments', []] } }, 2] },
{ $multiply: [{ $size: { $ifNull: ['$shares', []] } }, 3] }
]
},
// Connection boost flag
isConnection: { $in: ['$authorId', connectionIds.map(id => mongoose.Types.ObjectId(id))] },
// Calculate hours since posted
hoursOld: {
$divide: [
{ $subtract: [new Date(), '$createdAt'] },
3600000 // milliseconds to hours
]
}
}
},
// Calculate final score with decay
{
$addFields: {
finalScore: {
$multiply: [
// Base score + connection bonus
{
$add: [
'$engagementScore',
{ $cond: ['$isConnection', 50, 0] } // +50 for connections
]
},
// Exponential decay: e^(-t/72) where t is hours
// 72-hour half-life means content older than 3 days has low visibility
{
$exp: {
$divide: [
{ $multiply: ['$hoursOld', -1] },
72
]
}
}
]
}
}
},
// Sort by calculated score
{ $sort: { finalScore: -1, createdAt: -1 } },
{ $skip: (page - 1) * limit },
{ $limit: limit },
// Populate author info
{
$lookup: {
from: 'persons',
localField: 'authorId',
foreignField: '_id',
as: 'author'
}
},
{ $unwind: '$author' }
])
return posts
}
// Update viewFeed to support both modes
const viewFeed = async (userId, algorithm = 'smart', page = 1, limit = 20) => {
if (algorithm === 'smart') {
return await viewPersonalizedFeed(userId, page, limit)
}
// Fallback to chronological
return await viewChronologicalFeed(userId, page, limit)
}API Update:
GET /api/v1/feeds/main?algorithm=smart
Query params:
- algorithm: enum (smart, chronological) default: smart
- page: integer
- limit: integer
Estimated Time: 4-5 hours Priority: 🟢 Enhancement Status: 🔄 Not Started
Current Issue: Search only by name, limited filtering
Files to Modify:
src/services/searchService.js- EnhancesearchMembers()with aggregationsrc/validations/searchValidation.js- Add new filter schemassrc/routes/v1/searchRoute.js- Update Swagger docs
Implementation:
// src/services/searchService.js
const searchMembers = async (filters, page = 1, limit = 20) => {
const { name, skills, location, company } = filters
const skip = (page - 1) * limit
const pipeline = []
// Base match
pipeline.push({ $match: { _destroy: false } })
// Name search (existing)
if (name && name.trim()) {
const searchRegex = new RegExp(name.trim(), 'i')
pipeline.push({
$match: {
$or: [
{ firstName: searchRegex },
{ lastName: searchRegex },
{ $expr: {
$regexMatch: {
input: { $concat: ['$firstName', ' ', '$lastName'] },
regex: name.trim(),
options: 'i'
}
}}
]
}
})
}
// Skills filter (NEW)
if (skills && skills.length > 0) {
pipeline.push(
{
$lookup: {
from: 'skills',
localField: '_id',
foreignField: 'personId',
as: 'userSkills'
}
},
{
$match: {
'userSkills.name': {
$in: skills.map(s => new RegExp(`^${s}$`, 'i'))
}
}
}
)
}
// Location filter (NEW)
if (location && location.trim()) {
const locationRegex = new RegExp(location.trim(), 'i')
pipeline.push({
$match: { location: locationRegex }
})
}
// Company filter (NEW) - current or past
if (company && company.trim()) {
pipeline.push(
{
$lookup: {
from: 'experiences',
localField: '_id',
foreignField: 'personId',
as: 'experiences'
}
},
{
$match: {
'experiences.company': new RegExp(company.trim(), 'i')
}
}
)
}
// Pagination
pipeline.push(
{ $skip: skip },
{ $limit: limit }
)
const members = await Person.aggregate(pipeline)
// Get total count (without pagination)
const countPipeline = pipeline.filter(stage => !stage.$skip && !stage.$limit)
countPipeline.push({ $count: 'total' })
const countResult = await Person.aggregate(countPipeline)
const totalCount = countResult[0]?.total || 0
return {
members,
filters: { name, skills, location, company },
pagination: {
currentPage: page,
totalPages: Math.ceil(totalCount / limit),
totalCount,
hasNextPage: page * limit < totalCount,
hasPrevPage: page > 1
}
}
}API Update:
GET /api/v1/search/members
Query params:
- name: string (existing)
- skills: string[] (NEW - comma-separated)
- location: string (NEW)
- company: string (NEW)
- page, limit
Example: /api/v1/search/members?name=john&skills=JavaScript,React&location=San Francisco
Estimated Time: 3-4 hours Priority: 🟢 Enhancement Status: 🔄 Not Started
Current Issue: Comments are flat, no nested reply structure
Files to Modify:
src/models/social/commentModel.js- Adddepthfieldsrc/services/socialService.js- AddgetThreadedComments()src/controllers/socialController.js- Add controllersrc/routes/v1/socialRoute.js- Add route
Model Update:
// src/models/social/commentModel.js
// Add to schema:
depth: {
type: Number,
default: 0,
max: 3,
min: 0
}
// Update validation in addComment to enforce depth limitImplementation:
// src/services/socialService.js
const getThreadedComments = async (postId, page = 1, limit = 20) => {
const skip = (page - 1) * limit
// Get top-level comments
const topLevelComments = await Comment.aggregate([
{
$match: {
postId: mongoose.Types.ObjectId(postId),
parentCommentId: null,
_destroy: false
}
},
{ $sort: { createdAt: -1 } },
{ $skip: skip },
{ $limit: limit },
// Use $graphLookup to get all nested replies
{
$graphLookup: {
from: 'comments',
startWith: '$_id',
connectFromField: '_id',
connectToField: 'parentCommentId',
as: 'replies',
maxDepth: 2, // depth 0 + 2 = max 3 levels
depthField: 'replyDepth',
restrictSearchWithMatch: { _destroy: false }
}
},
// Populate author info
{
$lookup: {
from: 'persons',
localField: 'authorId',
foreignField: '_id',
as: 'author'
}
},
{ $unwind: '$author' }
])
// Build nested tree structure
const threaded = topLevelComments.map(comment => buildCommentTree(comment))
// Get total count of top-level comments
const totalCount = await Comment.countDocuments({
postId,
parentCommentId: null,
_destroy: false
})
return {
comments: threaded,
pagination: {
currentPage: page,
totalPages: Math.ceil(totalCount / limit),
totalCount,
hasNextPage: page * limit < totalCount,
hasPrevPage: page > 1
}
}
}
const buildCommentTree = (comment) => {
const replies = comment.replies || []
// Build tree recursively
const directReplies = replies
.filter(r => r.parentCommentId?.toString() === comment._id.toString())
.map(reply => {
const nestedReplies = replies.filter(
rr => rr.parentCommentId?.toString() === reply._id.toString()
)
return {
...reply,
replies: nestedReplies.map(nr => ({
...nr,
replies: [] // Max depth 3, no more nesting
}))
}
})
return {
...comment,
replies: directReplies
}
}New API Endpoint:
GET /api/v1/posts/:id/comments/threaded
Query params:
- page: integer
- limit: integer
Response: {
comments: [
{
id: "...",
content: "Top level comment",
author: {...},
replies: [
{
id: "...",
content: "Reply to top level",
author: {...},
replies: [
{
id: "...",
content: "Reply to reply",
author: {...},
replies: [] // Max depth
}
]
}
]
}
],
pagination: {...}
}
Estimated Time: 4-5 hours Priority: 🔴 Critical Status: 🔄 Not Started
Tasks:
-
Integration Testing (2-3 hours)
- Test Profile CRUD + media uploads
- Test Post creation with media files
- Test Comment + Reply flow
- Test Connection request/accept flow
- Test Search with all filters
- Test Project collaboration flow
-
API Documentation (1-2 hours)
- Verify all Swagger docs are complete
- Test all endpoints in Swagger UI
- Create Postman collection for Dev A domain
- Document authentication flow
-
Setup Documentation (1 hour)
- Update README with:
- Cloudinary setup instructions
- Environment variables for Dev A domain
- How to test media uploads locally
- How to use the API
- Update README with:
Environment Variables Checklist:
# Required for Dev A domain:
CLOUDINARY_CLOUD_NAME=your-cloud-name
CLOUDINARY_API_KEY=your-api-key
CLOUDINARY_API_SECRET=your-api-secret
BREVO_API_KEY=your-brevo-key (already configured)
EMAIL_SENDER_EMAIL=noreply@networkr.com (already configured)| Day | Task | Hours | Priority | Status |
|---|---|---|---|---|
| 1-2 | Media Uploads (Profile + Social) | 8-10 | 🔴 Critical | 🔄 Not Started |
| 3 | Profile Completeness API | 4-5 | 🟡 Important | 🔄 Not Started |
| 4-5 | Notification Integration | 8-10 | 🟡 Important | ⏸️ Blocked by Dev B |
| Day | Task | Hours | Priority | Status |
|---|---|---|---|---|
| 1-2 | Smart Feed Algorithm | 6-8 | 🟢 Nice-to-have | 🔄 Not Started |
| 3 | Enhanced Profile Search | 4-5 | 🟢 Nice-to-have | 🔄 Not Started |
| 4 | Comment Threading | 3-4 | 🟢 Nice-to-have | 🔄 Not Started |
| 5 | Testing & Documentation | 4-5 | 🔴 Important | 🔄 Not Started |
Total Estimated Time: 37-47 hours
Must Do (Week 5):
- ✅ Media Uploads - Users need real photo upload capability
- ✅ Profile Completeness - Guides user onboarding
- ⏸️ Notification Integration - Wait for Dev B, then integrate triggers
Should Do (Week 6): 4. ⭐ Smart Feed - Better content discovery and engagement 5. ⭐ Enhanced Search - More useful member search 6. ✅ Testing & Documentation - Always critical
Could Do (Week 6): 7. 📝 Comment Threading - Better discussion UX
Dev B must complete first (Week 5 Day 1-4):
- Notification Service infrastructure (
src/services/notificationService.js) - Notification Controller & Routes (
src/controllers/notificationController.js) - Socket.io setup (
src/config/socket.jsandsrc/server.js)
Then Dev A integrates (Week 5 Day 4-5):
- Add notification triggers in
networkService,projectService,socialService - Test real-time notification delivery
- Verify email notifications for project invitations
| Method | Endpoint | Description |
|---|---|---|
| POST | /registrations |
Register (OTP expires in 10 min) |
| POST | /verifications |
Verify OTP (checks expiry) |
| POST | /verifications/resend |
Resend OTP (sets new expiry) |
| POST | /password-resets |
Request password reset |
| PATCH | /password-resets |
Complete reset with token |
| PATCH | /sessions/password |
Change password |
| Method | Endpoint | Description |
|---|---|---|
| GET | /users |
Search members |
| GET | /users/me |
Get my profile |
| PATCH | /users/me/profile |
Edit summary |
| POST | /users/me/experiences |
Add experience |
| POST | /posts |
Create post (URL-based media) |
| POST | /posts/:id/interactions |
Like/Share/Save |
| POST | /posts/:id/comments |
Add comment |
| POST | /network/invitations |
Send connection |
| PUT | /network/invitations/:id/accept |
Accept connection |
| GET | /search/members |
Search members |
| GET | /search/companies |
Search companies |
| POST | /search/candidates/filter |
Filter candidates |
| POST | /projects/:id/collaborators/invite |
Invite to project |
| Method | Endpoint | Description |
|---|---|---|
| POST | /users/me/avatar |
Upload avatar (5MB) |
| POST | /users/me/background |
Upload background (10MB) |
| POST | /posts |
Create post with media files |
| GET | /users/me/completeness |
Profile score & tips |
| Method | Endpoint | Description |
|---|---|---|
| GET | /feeds/main?algorithm=smart |
Personalized feed |
| GET | /search/members |
Enhanced search with filters |
| GET | /posts/:id/comments/threaded |
Nested comments |