diff --git a/src/controllers/groupController.js b/src/controllers/groupController.js index 3ab2644..a675044 100644 --- a/src/controllers/groupController.js +++ b/src/controllers/groupController.js @@ -1,35 +1,72 @@ const groupModel = require('../models/groupModel'); const validator = require('validator'); +const redisClient = require('../services/redisServer'); const { queueInviteEmailSending } = require('../services/emailQueueProducer'); const logger = require('../../logger'); +const inviteCodeTemplate = require('./helpers/inviteCodeTemplate'); + +const { generateInviteCode, validateEmail } = require('./helpers/groupHelpers'); + +// redis client configs +async function sendInviteViaRedis(groupById, normalizedEmail, inviteCode) { + const inviteKey = `${groupById.groupName}:invite:${inviteCode}`; + const redisInviteCodeSet = JSON.stringify({ + groupId: groupById._id, + email: normalizedEmail, + groupName: groupById.groupName, + }); + + await redisClient.set(inviteKey, redisInviteCodeSet, 'EX', 2 * 24 * 60 * 60, (err) => { + if (err) { + console.error('Error setting invite code in redis'); + throw new Error(err); + } else { + console.log(`Invite data successfully saved in Redis under key ${inviteKey}`); + } + }); +} -// TODO: implement the stage of create group(add group member via invite) module.exports.createGroup = async (req, res) => { - const { stage, groupData } = req.body; + const { stage, groupData, members } = req.body; try { // Stage 1: Group name and group description(optional) if (stage == 1) { // Extract the nested group details from groupData const { groupName, description } = groupData; - if (!groupName || !validator.isLength(groupName.trim(), { min: 1 })) { + if (!groupName) { // description is optional return res.status(400).json({ message: 'Group name is required.' }); + } else if (!validator.isLength(description.trim(), { min: 1, max: 100 })) { + return res + .status(400) + .json({ message: 'Description must be between 1 to 100 characters.' }); } // Sanitize the input before saving const sanitizedGroupName = validator.escape(groupName.trim()); const sanitizedGroupDescription = description ? validator.escape(description.trim()) : ' '; + // Create the group (no members yet) const newgroup = await groupModel.create({ groupName: sanitizedGroupName, description: sanitizedGroupDescription, createdBy: req.user.id, + members: [ + { + user: req.user.id, + role: 'admin', + status: 'active', + email: req.user.email, + inviteCode: generateInviteCode(), + joinedAt: new Date(), + }, + ], }); // save the groupInfo to DB(without group members) await newgroup.save(); return res.status(200).json({ - message: 'Group created successfully. Proceed to add members.', + message: 'Group created successfully.', groupId: newgroup._id, nextStage: 2, }); @@ -38,43 +75,81 @@ module.exports.createGroup = async (req, res) => { // Stage 2: Add member and send invites if (stage == 2) { const { groupId } = groupData; - const { members } = req.body; - if (!groupId || !members || members.length === 0) { - return res.status(400).json({ - message: "Group ID and member's email required", - }); - } + + // validate the group id const groupById = await groupModel.findById({ groupId }); if (!groupById) { return res.status(404).json({ message: 'Group not found.' }); } + // Validate members array + if (!Array.isArray(members) || members.length === 0) { + return res.status(400).json({ + message: 'Please provide at least one member email', + }); + } + // Send emails to all the members mentioned // make sure the member.length <=25 - if (members.length > 25) { + if (members.length > groupById.settings.maxMembers - groupById.members.length) { return res.status(400).json({ - message: 'Members length exceeds 25', + message: `Cannot add more than ${groupById.settings.maxMembers} members to a group`, }); } + + const newMembers = []; + const emailPromises = []; // Verify each email if it is valid or not then add to redis queue - for (const member of members) { - if (!validator.isEmail(member)) { - return res.status(400).json({ message: `Invalid email: ${member}` }); + for (const email of members) { + const normalizedEmail = validateEmail(email); + if (!normalizedEmail) { + return res.status(400).json({ message: `Invalid email: ${email}` }); } + // check email is already present + if (groupById.members.some((member) => member.email === normalizedEmail)) { + { + return res.status(400).jsoN({ message: `${email} already in the group` }); + } + } + + const inviteCode = generateInviteCode(); + sendInviteViaRedis(groupById, normalizedEmail, inviteCode); + + newMembers.push({ + email: normalizedEmail, + status: 'pending', + role: 'member', + inviteCode, + invitedAt: new Date(), + }); + + const inviteLink = `${process.env.FRONTEND_URL}/groups/join?code=${inviteCode}&group=${groupById.groupName}`; + // construct a mail option to send as invite const mailOptions = { from: `"SplitBhai Team" `, - to: member, - subject: `You're Invited to join the ${groupById.groupName} by your friends on SplitBhai`, - text: `Hello, \n\nYou have been invited to join the group "${groupById.groupName}". Use this invite code to join: ${groupById._id}.`, + to: normalizedEmail, + subject: `You're Invited to join the ${groupById.groupName} by your friend ${req.user.name} on SplitBhai`, + text: inviteCodeTemplate({ + groupName: groupById.groupName, + inviterName: req.user.name, + inviteLink, + inviteCode, + }), }; try { await queueInviteEmailSending(mailOptions); + groupById.members.push(...newMembers); + await groupById.save(); + + return res + .status(200) + .json({ message: 'Members invited successfully', data: { groupId: groupById._id } }); } catch (error) { - logger.error(`Error adding emails to invite queue: ${error.message}`); - return res.status(500).json({ message: 'Error processing invites. Please try again.' }); + logger.error('Error while inviting members'); + return res.status(400).json({ message: 'Invalid stage' }); } } @@ -86,25 +161,50 @@ module.exports.createGroup = async (req, res) => { } }; -// TODO: add option to check if user is signed in or not (redirect to register/login if nots) module.exports.joinGroup = async (req, res) => { - const { groupName, groupCode } = req.body; + // const { groupName, inviteCode } = req.body; + const { group: groupName, code: inviteCode } = req.query; try { // Validate input data - if (!groupCode || !groupName) { + if (!groupName || !inviteCode) { return res.status(400).json({ - message: 'Missing required fields', + message: 'Group name and invite code are required', }); } - // Find group by name and code - const group = await groupModel.findOne({ - groupName: groupName.trim(), - _id: groupCode, + + // construct redis inviteKey + const inviteKey = `${groupName.trim()}:invite${inviteCode}`; + const redisJoinInviteData = await new Promise((resolve, reject) => { + redisClient.get(inviteKey, (err, data) => { + if (err) { + reject(err); + } else { + res(data); + } + }); }); + if (!redisJoinInviteData) { + return res.status(400).json({ message: 'Invalid or exired invite code' }); + } + + const inviteData = JSON.parse(redisJoinInviteData); + + // Find group by name and code + const group = await groupModel.findById(inviteData.groupId); + if (!group) { - return res.status(404).json({ message: 'Group code or group name is invalid' }); + return res.status(404).json({ message: 'Invalid group name or invite code' }); + } + + // find pending invite code + const memberIndex = group.members.findIndex( + (mem) => mem.inviteCode === inviteCode && mem.status === 'pending' + ); + + if (memberIndex === -1) { + return res.status(400).json({ message: 'Invite code is not valid or already used ' }); } // Check if user is already part of the group @@ -114,10 +214,21 @@ module.exports.joinGroup = async (req, res) => { return res.status(400).json({ message: 'You are already a part of this group' }); } - // Add the user to the group - group.members.push({ user: req.user.id, role: 'member' }); + // Update the users details and save to dattabase + group.members[memberIndex].user = req.user._id; + group.members[memberIndex].status = 'active'; + group.members[memberIndex].joinedAt = new Date(); + await group.save(); + // remove the invite key from redis + await new Promise((resolve, reject) => { + redisClient.del(inviteKey, (err) => { + if (err) reject(err); + resolve(); + }); + }); + // Return a success message return res.status(200).json({ message: 'You have successfully joined the group', diff --git a/src/controllers/helpers/groupHelpers.js b/src/controllers/helpers/groupHelpers.js new file mode 100644 index 0000000..d2bddd3 --- /dev/null +++ b/src/controllers/helpers/groupHelpers.js @@ -0,0 +1,34 @@ +// Helper function to generate the invite code +const crypto = require('crypto'); + +const generateInviteCode = () => { + return crypto.randomBytes(6).toString('hex'); +}; + +// validate email helper function +const validateEmail = (email) => { + return validator.isEmail(email) && validator.normalizeEmail(email); +}; + +// helper function to verify if user is admin +const isGroupAdmin = async (req, res, next) => { + try { + const group = await groupModel.findById(req.params.groupId); + if (!group) { + return res.status(400).json({ message: 'Group not found' }); + } + const memeberRecord = group.members.find((mem) => { + mem.user.toString() === req.user.id && m.role === 'admin'; + }); + if (!memeberRecord) { + return res.status(403).json({ message: 'Only group admins can perform this action' }); + } + req.group = group; + next(); + } catch (error) { + logger.error('Error in isGroupAdmin middleware:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}; + +module.exports = (generateInviteCode, validateEmail); diff --git a/src/controllers/helpers/inviteCodeTemplate.js b/src/controllers/helpers/inviteCodeTemplate.js new file mode 100644 index 0000000..663a4ee --- /dev/null +++ b/src/controllers/helpers/inviteCodeTemplate.js @@ -0,0 +1,167 @@ +function generateInviteEmailTemplate({ groupName, inviterName, inviteLink, inviteCode }) { + return ` + + + + + + Join ${groupName} on SplitBhai + + + +
+ +
+ + + `; +} + +module.exports = generateInviteEmailTemplate; diff --git a/src/controllers/splitLogicController.js b/src/controllers/splitLogicController.js index ba010d0..1f7a046 100644 --- a/src/controllers/splitLogicController.js +++ b/src/controllers/splitLogicController.js @@ -125,6 +125,7 @@ const splitCustom = (amount, paidBy, selectedMembers, customAmounts, isPaidByInc */ if (member === paidBy) { return { + Recieves: true, member, owes: thismemeberOwes.toFixed(2), description: `Paid Rs. ${amount} and will get ${amount - memberOwes} back`, diff --git a/src/models/groupModel.js b/src/models/groupModel.js index c8299ba..0d06334 100644 --- a/src/models/groupModel.js +++ b/src/models/groupModel.js @@ -1,56 +1,111 @@ -const mongoose = require("mongoose"); +const mongoose = require('mongoose'); +const { isLowercase, trim } = require('validator'); -const groupSchema = new mongoose.Schema({ - groupName: { - type: String, - required: true, - trim: true, - }, - description: { - type: String, - trim: true, - maxLength: [40, "Description cannot be more than 40 characters"], - }, - groupCode: { - type: String, - required: true, - unique: true, - }, - members: [ - { - user: { +const groupSchema = new mongoose.Schema( + { + groupName: { + type: String, + required: true, + trim: true, + minLength: 1, + maxLength: 100, + }, + description: { + type: String, + trim: true, + maxLength: [100, 'Description cannot be more than 100 characters'], + }, + inviteCode: { + type: String, + required: true, + unique: true, + }, + createdBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + unique: true, + }, + members: [ + { + user: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + }, + role: { + type: String, + enum: ['admin', 'member'], + default: 'member', + }, + status: { + type: String, + enum: ['pending', 'active', 'inactive'], + default: 'pending', + }, + email: { + type: String, + required: true, + lowercase: true, + trim: true, + }, + inviteCode: { + type: String, + required: true, + }, + invitedAt: { + type: Date, + default: Date.now, + }, + joinedAt: Date, + }, + ], + events: [ + { type: mongoose.Schema.Types.ObjectId, - ref: "User", - required: true, + ref: 'Event', }, - role: { - type: String, - enum: ["admin", "member"], - default: "member", + ], + maxBarterAmount: { + type: Number, + min: 0, + required: true, + }, + settings: { + isPublic: { + type: Boolean, + default: false, + }, + allowInvites: { + type: Boolean, + default: true, + }, + maxMembers: { + type: Number, + default: 25, }, }, - ], - events: [ - { - type: mongoose.Schema.Types.ObjectId, - ref: "Event", + createdAt: { + type: Date, + default: Date.now, + }, + + updatedAt: { + type: Date, + default: Date.now(), }, - ], - maxBarterAmount: { - type: Number, - min: 0, - required: true, - }, - createdAt: { - type: Date, - default: Date.now, - }, - createdBy: { - type: mongoose.Schema.Types.ObjectId, - ref: "User", - required: true, - unique: true, }, + { timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' } } +); + +// add indexes for better performance +groupSchema.index({ groupName: 1 }); +groupSchema.index({ 'members.email': 1 }); +groupSchema.index({ inviteCode: 1 }); + +groupSchema.pre('save', (next) => { + this.updatedAt = Date.now(); + next(); }); -module.exports = mongoose.model("Group", groupSchema); +module.exports = mongoose.model('Group', groupSchema); diff --git a/src/settlements/settleController/settleEqual.js b/src/settlements/settleController/settleEqual.js new file mode 100644 index 0000000..4c5fc08 --- /dev/null +++ b/src/settlements/settleController/settleEqual.js @@ -0,0 +1 @@ +module.exports.equalPay = async () => {}; diff --git a/src/utils/mailOptions.js b/src/utils/mailOptions.js index a81d8cd..c3f7599 100644 --- a/src/utils/mailOptions.js +++ b/src/utils/mailOptions.js @@ -1,7 +1,7 @@ // custom mail options based on the params passed for different use cases // separation of concerns -function mailOptions({ from, to, subject, text }) { +function validateMailOptions({ from, to, subject, text }) { const defaultFrom = 'noreply@splitbhai.com'; if (!from || typeof from !== 'string') { from = defaultFrom; @@ -26,4 +26,4 @@ function mailOptions({ from, to, subject, text }) { }; } -module.exports = mailOptions; +module.exports = validateMailOptions;