diff --git a/docker-compose.yml b/docker-compose.yml index 450fd7ac..8ee57660 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,15 @@ services: + # MongoDB database + mongo: + profiles: ["web_app"] + image: mongo:6 + container_name: mongo + volumes: + - mongo_data:/data/db + ports: + - "27017:27017" + networks: + - gmail-net # React frontend web_front: profiles: ["web_app"] @@ -26,6 +37,9 @@ services: - ./web_server/.env depends_on: - run_server + - mongo + environment: + - MONGO_URI=mongodb://mongo:27017/appdb ports: - "${NODE_PORT:-3001}:3001" networks: @@ -91,6 +105,7 @@ services: volumes: app_data: + mongo_data: networks: gmail-net: \ No newline at end of file diff --git a/web_server/.env b/web_server/.env index a344b204..a749e6e2 100644 --- a/web_server/.env +++ b/web_server/.env @@ -1,5 +1,5 @@ -REACT_URL=http://localhost:3000 NODE_PORT=3001 JSON_LIMIT=10mb JWT_SECRET=gradingMode -JWT_EXPIRATION_TIME=24h \ No newline at end of file +JWT_EXPIRATION_TIME=24h +MONGO_URI=mongodb://localhost:27017/appdb diff --git a/web_server/.env.example b/web_server/.env.example new file mode 100644 index 00000000..91e29f52 --- /dev/null +++ b/web_server/.env.example @@ -0,0 +1,4 @@ +NODE_PORT=3001 +JWT_SECRET=replace_me +JWT_EXPIRATION_TIME=24h +MONGO_URI=mongodb://mongo:27017/appdb diff --git a/web_server/README.md b/web_server/README.md index 962d1a95..7aa2934c 100644 --- a/web_server/README.md +++ b/web_server/README.md @@ -24,6 +24,24 @@ And build it **Make sure you already cloned the project and built it using docker compose**
In case you want to change any configuration value related to the bloom filter (port, bloom filter integers etc.) change the relevant dockerfile or docker compose file. +- To run the web server and MongoDB via compose (profile web_app) +```bash +docker compose --profile web_app up -d --build web_server mongo +``` + +- Environment + - Ensure `.env` exists in `web_server/` or use `.env.example` as a template. + - `MONGO_URI` should be `mongodb://mongo:27017/appdb` when using compose. + +- Health check +```bash +curl http://localhost:3001/health +``` + +- Dev routes + - Disabled by default in `app.js` (commented `app.use('/dev/*', ...)`). + - Uncomment locally only if you need seed/list endpoints for debugging. + - To run the web server and bloom filter server ```bash docker-compose up web_server diff --git a/web_server/app.js b/web_server/app.js index eb7b5c1c..4348f8c0 100644 --- a/web_server/app.js +++ b/web_server/app.js @@ -17,10 +17,32 @@ const inbox = require('./routes/mails'); const users = require('./routes/users'); const labels = require('./routes/labels'); const blacklist = require('./routes/blacklist'); +const { connectDB } = require('./config/db'); +const health = require('./routes/health'); +// Dev routes are disabled by default. Uncomment if needed for local debugging. +// const devUsers = require('./routes/devUsers'); +// const devLabels = require('./routes/devLabels'); +// const devMails = require('./routes/devMails'); app.use('/api/mails', inbox); app.use('/api', users); app.use('/api/labels', labels); app.use('/api/blacklist', blacklist); +app.use('/health', health); +// app.use('/dev/users', devUsers); +// app.use('/dev/labels', devLabels); +// app.use('/dev/mails', devMails); -app.listen(PORT); +const start = async () => { + try { + await connectDB(); + app.listen(PORT, () => { + console.log(`Server listening on port ${PORT}`); + }); + } catch (err) { + console.error('Failed to start server:', err && err.message ? err.message : err); + process.exit(1); + } +}; + +start(); diff --git a/web_server/config/db.js b/web_server/config/db.js new file mode 100644 index 00000000..d2147a2a --- /dev/null +++ b/web_server/config/db.js @@ -0,0 +1,51 @@ +const mongoose = require('mongoose'); + +mongoose.set('strictQuery', true); + +const MAX_RETRIES = 3; +const RETRY_DELAYS_MS = [1000, 2000, 4000]; + +function addConnectionListeners() { + const connection = mongoose.connection; + connection.on('connected', () => { + console.log('Mongo connected'); + }); + connection.on('error', (err) => { + console.error('Mongo connection error:', err && err.message ? err.message : err); + }); + connection.on('disconnected', () => { + console.warn('Mongo disconnected'); + }); +} + +async function connectDB() { + addConnectionListeners(); + + const mongoUri = process.env.MONGO_URI; + + if (!mongoUri) { + throw new Error('MONGO_URI is not defined in environment variables'); + } + + for (let attemptIndex = 0; attemptIndex < MAX_RETRIES; attemptIndex += 1) { + try { + await mongoose.connect(mongoUri); + return mongoose.connection; + } catch (error) { + const isLastAttempt = attemptIndex === MAX_RETRIES - 1; + if (isLastAttempt) { + throw error; + } + const delay = RETRY_DELAYS_MS[attemptIndex] || 1000; + console.warn(`Mongo connection attempt ${attemptIndex + 1} failed. Retrying in ${delay}ms...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + // Should never reach here + throw new Error('Unknown Mongo connection error'); +} + +module.exports = { connectDB }; + + diff --git a/web_server/controllers/labels.js b/web_server/controllers/labels.js index 00b0c766..73fdd6ba 100644 --- a/web_server/controllers/labels.js +++ b/web_server/controllers/labels.js @@ -5,9 +5,9 @@ const Labels = require('../models/labels'); * Return all labels (flat list), belonging only to the authenticated user. * Responds: [{ id, name, parent }] */ -exports.getAllLabels = (req, res) => { +exports.getAllLabels = async (req, res) => { const userId = +req.user.id; - const all = Labels.getAllLabels(userId); + const all = await Labels.getAllLabels(userId); // Only send fields required by frontend; "parent" is id of parent label (or null for root) res.status(200).json(all.map(l => ({ id: l.id, @@ -22,12 +22,12 @@ exports.getAllLabels = (req, res) => { * Request body: { name } * Responds: created label object, 201 status. */ -exports.createNewLabel = (req, res) => { +exports.createNewLabel = async (req, res) => { // User ID from authentication middleware const userId = +req.user.id; const { name } = req.body; if (!name) return res.status(400).json({ error: 'Name required' }); - const lab = Labels.createNewLabel(userId, name); + const lab = await Labels.createNewLabel(userId, name); if (!lab) return res.status(409).json({ error: 'Label name already exists' }); res.status(201).location(`/api/labels/${lab.id}`).json(lab); }; @@ -38,13 +38,13 @@ exports.createNewLabel = (req, res) => { * Request body: { name } * Responds: created sublabel object, or 404 if parent not found. */ -exports.createSublabel = (req, res) => { +exports.createSublabel = async (req, res) => { const userId = +req.user.id; const parentId = +req.params.id; const { name } = req.body; if (!name) return res.status(400).json({ error: 'Name required' }); // Model should handle parent existence/ownership - const lab = Labels.createSublabel(userId, parentId, name); + const lab = await Labels.createSublabel(userId, parentId, name); if (!lab) return res.status(404).json({ error: 'Parent not found' }); else if (!lab) return res.status(409).json({ error: 'Label name already exists' }); res.status(201).location(`/api/labels/${lab.id}`).json(lab); @@ -56,15 +56,19 @@ exports.createSublabel = (req, res) => { * Request body: { name } * Responds: 204 on success, 404 if not found. */ -exports.editLabel = (req, res) => { +exports.editLabel = async (req, res) => { const id = +req.params.id; const userId = +req.user.id; const { name } = req.body; if (!name) return res.status(400).json({ error: 'Name required' }); - const ok = Labels.editLabelById(id,userId, name); - if (!ok) return res.status(404).json({ error: 'Not found' }); - - res.status(204).end(); + try { + const ok = await Labels.editLabelById(id, userId, name); + if (!ok) return res.status(404).json({ error: 'Not found' }); + res.status(204).end(); + } catch (e) { + if (String(e && e.message) === '409') return res.status(409).json({ error: 'Label name already exists' }); + return res.status(500).json({ error: 'Internal error' }); + } }; /** @@ -72,14 +76,14 @@ exports.editLabel = (req, res) => { * Delete label (and, usually, its sublabels) by ID. * Responds: 204 on success, 404 if not found. */ -exports.deleteLabel = (req, res) => { +exports.deleteLabel = async (req, res) => { const labelId = +req.params.id; const userId = +req.user.id; if (!(labelId)) return res.status(400).json({ error: 'Invalid label ID' }); - const deleted = Labels.deleteLabelById(labelId, userId); + const deleted = await Labels.deleteLabelById(labelId, userId); if (!deleted) return res.status(404).json({ error: 'Label not found or not owned by user' }); diff --git a/web_server/controllers/mails.js b/web_server/controllers/mails.js index 0d2ad757..f9fd7de3 100644 --- a/web_server/controllers/mails.js +++ b/web_server/controllers/mails.js @@ -12,7 +12,7 @@ const {convertLabelsToIds, labelsToFullElement} = require("../utils/labels"); * - code 200 and list of ordered by the time sent mails objects * - code 400 if user isn't authenticated */ -const getLastMailsOrdered = (req, res) => { +const getLastMailsOrdered = async (req, res) => { // Make sure the user is authenticated, if not - a bad request (400) const userId = Number(req.user.id); if (!userId) @@ -23,7 +23,7 @@ const getLastMailsOrdered = (req, res) => { const limit = Number(req.query.limit) || 50; const labelIdFilter = Number(req.query.label); - let {paged, total} = Mails.getUserMails(userId, limit, inboxType, page); + let {paged, total} = await Mails.getUserMails(userId, limit, inboxType, page); // Filter by label id if provided if (!isNaN(labelIdFilter)) { @@ -32,14 +32,14 @@ const getLastMailsOrdered = (req, res) => { } // replace in the mails the user ids and labels ids with user and label elements so we can show names and emails - const fullMails = paged.map(m => { + const fullMails = await Promise.all(paged.map(async (m) => { return { ...m, - from: usersToFullElement([m.from])[0], - sentTo: usersToFullElement(m.sentTo || []), - labels: labelsToFullElement(userId, m.labels || []) + from: (await usersToFullElement([m.from]))[0], + sentTo: await usersToFullElement(m.sentTo || []), + labels: await labelsToFullElement(userId, m.labels || []) } - }) + })) // we weren't instructed to return 404 if mails is empty, just do a 200 code one return res.status(200).json( { @@ -58,7 +58,7 @@ const getLastMailsOrdered = (req, res) => { * - code 400 if there's no id in input * - code 404 if there's no mail with the id */ -const getMailById = (req, res) => { +const getMailById = async (req, res) => { // Make sure we got an id in the request const id = parseInt(req.params.id); if (isNaN(id)) @@ -68,7 +68,7 @@ const getMailById = (req, res) => { if (!userId) return res.status(400).json({error: 'No valid user ID was given'}); // Find the mail with the given id - const mail = Mails.getMail(id); + const mail = await Mails.getMail(id); // Return 404 if not found, or a 200 with the mail as a json object if (!mail) return res.status(404).json({error: `No mail found with ID: ${id}`}); @@ -77,9 +77,9 @@ const getMailById = (req, res) => { return res.status(403).json({error: 'mail do not belong to user'}); return res.status(200).json({ ...mail, - from: usersToFullElement([mail.from])[0], - sentTo: usersToFullElement(mail.sentTo || []), - labels: labelsToFullElement(userId, mail.labels || []) + from: (await usersToFullElement([mail.from]))[0], + sentTo: await usersToFullElement(mail.sentTo || []), + labels: await labelsToFullElement(userId, mail.labels || []) }); } @@ -103,10 +103,10 @@ const createNewMail = async (req, res) => { try { // Fetch the mail object's attributes from the request, for later use const {subject, body, sentTo = [], saveAsDraft = false, files = []} = req.body; - const sentToIds = convertMailsToIds(sentTo); + const sentToIds = await convertMailsToIds(sentTo); // handle this as a draft if (saveAsDraft) { - const draftMail = Mails.saveDraft(userId, subject, body, sentToIds, files) + const draftMail = await Mails.saveDraft(userId, subject, body, sentToIds, files) return res.status(201).location(`/mails/${draftMail.id}`).json(draftMail); } // handle this as sending a mail @@ -116,7 +116,7 @@ const createNewMail = async (req, res) => { if (blacklisted) return res.status(403).json({error: 'Mail contains blacklisted URLs - failed creating a new mail'}); // No blacklisted URLs found, send the new mail - const newMail = Mails.sendNewMail(userId, subject, body, sentToIds, files); + const newMail = await Mails.sendNewMail(userId, subject, body, sentToIds, files); if (newMail === false) return res.status(500).json({error: 'Failed to create new mail'}); return res.status(201).location(`/mails/${newMail.id}`).json(newMail); @@ -136,7 +136,7 @@ const createNewMail = async (req, res) => { * - code 400 is user isn't authenticated * - code 500 if any other error occurred */ -const updateMail = (req, res) => { +const updateMail = async (req, res) => { // Make sure the user is authenticated, if not - a bad request (400) const userId = Number(req.user.id); if (!userId) @@ -145,8 +145,8 @@ const updateMail = (req, res) => { const mailId = Number(req.params.id); if (isNaN(mailId)) return res.status(400).json({error: 'error no valid mail id was given'}); - const mail = Mails.getMail(mailId); - if (!mailId || mail.owner != userId) + const mail = await Mails.getMail(mailId); + if (!mail || mail.owner != userId) return res.status(404).json({error: 'error mail not found'}); // edit it as a draft if (mail.isDraft) @@ -155,7 +155,7 @@ const updateMail = (req, res) => { // otherwise it’s a mail already sent - only allow flags & labels const {isRead, isStarred, isTrashed, isSpam, labels} = req.body || {}; const labelsIds = convertLabelsToIds(userId, labels || []); - const updated = Mails.editSentMail(mailId, isRead, isStarred, isTrashed, isSpam, labelsIds); + const updated = await Mails.editSentMail(mailId, isRead, isStarred, isTrashed, isSpam, labelsIds); if (updated) return res.status(200).json(updated); if (updated === 404) @@ -181,11 +181,11 @@ const editDraft = async (req, res, userId, mail) => { return res.status(400).json({error: 'error only drafts can be updated'}); // Get the input params and edit the mail const {subject, body, sentTo = [], saveAsDraft = true, files = []} = req.body; - const sentToIds = convertMailsToIds(sentTo); + const sentToIds = await convertMailsToIds(sentTo); // just update the fields in this draft if (saveAsDraft) { - const updated = Mails.updateDraft(mail.id, subject, body, sentToIds, files); + const updated = await Mails.updateDraft(mail.id, subject, body, sentToIds, files); return res.status(200).json(updated); } // turn the draft to a new mail @@ -193,9 +193,9 @@ const editDraft = async (req, res, userId, mail) => { const blacklisted = await Blacklist.isInBlacklist(urls); if (blacklisted) return res.status(403).json({error: 'error mail contains blacklisted URLs'}); - // delete the draft and send a new mail - Mails.deleteMail(userId, mail.id); - const ownerMail = Mails.sendNewMail(userId, subject, body, sentToIds, files); + // send a new mail and then delete the draft + const ownerMail = await Mails.sendNewMail(userId, subject, body, sentToIds, files); + await Mails.deleteMail(userId, mail.id); return res.status(201).location(`/mails/${ownerMail.id}`).json(ownerMail); } @@ -209,7 +209,7 @@ const editDraft = async (req, res, userId, mail) => { * - 404 if mail not found * - 400 if missing input or user isn't authenticated or has no access to the mail */ -const deleteMailById = (req, res) => { +const deleteMailById = async (req, res) => { // Make sure we got an id in the request const mailId = Number(req.params.id); if (isNaN(mailId)) @@ -219,7 +219,7 @@ const deleteMailById = (req, res) => { if (isNaN(userId)) return res.status(400).json({error: 'User not authenticated'}); // Delete the desired mail and return matching result - const mail = Mails.deleteMail(userId, mailId); + const mail = await Mails.deleteMail(userId, mailId); if (mail === 404) return res.status(404).json({error: 'mail was not found'}); if (mail === 400) @@ -235,7 +235,7 @@ const deleteMailById = (req, res) => { * @returns {*} array of mails objects that contains the query, if encountered a problem returns code 400 * with the description. */ -const getMailsByQuery = (req, res) => { +const getMailsByQuery = async (req, res) => { // Make sure the user is authenticated, if not - a bad request (400) const userId = Number(req.user.id); if (!userId) @@ -245,15 +245,15 @@ const getMailsByQuery = (req, res) => { if (!query) return res.status(400).json({error: 'Empty query'}); // Find the mails and return them, if there are no mails returns an empty array - const rawMails = Mails.searchInInbox(query, userId); - const fullMails = rawMails.map(m => { + const rawMails = await Mails.searchInInbox(query, userId); + const fullMails = await Promise.all(rawMails.map(async (m) => { return { ...m, - from: usersToFullElement([m.from])[0], - sentTo: usersToFullElement(m.sentTo || []), - labels: labelsToFullElement(userId, m.labels || []) + from: (await usersToFullElement([m.from]))[0], + sentTo: await usersToFullElement(m.sentTo || []), + labels: await labelsToFullElement(userId, m.labels || []) } - }) + })) return res.status(200).json(fullMails); } diff --git a/web_server/controllers/users.js b/web_server/controllers/users.js index f512a863..41a032b2 100644 --- a/web_server/controllers/users.js +++ b/web_server/controllers/users.js @@ -21,7 +21,7 @@ const signupUser = async (req, res) => { } // Don't create if the mail is already taken - if (Users.userExist(mail)) { + if (await Users.userExist(mail)) { return res.status(400).json({ error: 'mail already exists' }); } @@ -50,13 +50,13 @@ const signupUser = async (req, res) => { * - 400 if invalid id * - 404 if no user found */ -const getUser = (req, res) => { +const getUser = async (req, res) => { const id = Number(req.params.id); if (!id || isNaN(id)) { return res.status(400).json({ error: 'Invalid user ID' }); } - const user = Users.getUserById(id); + const user = await Users.getUserById(id); if (!user) { return res.status(404).json({ error: 'User not found' }); } @@ -81,13 +81,13 @@ const loginUser = async (req, res) => { return res.status(400).json({ error: 'mail and password required' }); } - const user = Users.isAuthorizeUser(mail, password); + const user = await Users.isAuthorizeUser(mail, password); if (!user) { return res.status(401).json({ error: 'wrong mail or password' }); } const token = signToken(user); - return res.status(200).json({ token }); + return res.status(200).json({ token, user }); }; /** @@ -143,13 +143,14 @@ const isTokenValid = (req, res) => { * - 200 and an array of `{ id, name, mail }` (max 10) * - 400 if missing query parameter `q` */ -const searchUsers = (req, res) => { +const searchUsers = async (req, res) => { const query = req.query.q?.toLowerCase(); if (!query) { return res.status(400).json({ error: 'Missing query parameter' }); } - const matched = Users.getAllUsers() + const all = await Users.getAllUsers(); + const matched = all .filter(user => user.fullName.toLowerCase().includes(query) || user.mail.toLowerCase().includes(query) diff --git a/web_server/db/models/Counter.js b/web_server/db/models/Counter.js new file mode 100644 index 00000000..91f2beea --- /dev/null +++ b/web_server/db/models/Counter.js @@ -0,0 +1,13 @@ +const mongoose = require('mongoose'); + +const counterSchema = new mongoose.Schema( + { + _id: { type: String, required: true }, + seq: { type: Number, default: 0 }, + }, + { collection: 'counters' } +); + +module.exports = mongoose.models.Counter || mongoose.model('Counter', counterSchema); + + diff --git a/web_server/db/models/Label.js b/web_server/db/models/Label.js new file mode 100644 index 00000000..b1a6d65c --- /dev/null +++ b/web_server/db/models/Label.js @@ -0,0 +1,21 @@ +const mongoose = require('mongoose'); + +const labelSchema = new mongoose.Schema( + { + id: { type: Number, unique: true, index: true }, + name: { type: String, required: true, trim: true }, + owner: { type: Number, required: true }, + parent: { type: Number, default: null }, + }, + { + timestamps: true, + collection: 'labels', + } +); + +// Prevent duplicate names for the same owner and parent +labelSchema.index({ owner: 1, name: 1, parent: 1 }, { unique: true }); + +module.exports = mongoose.models.Label || mongoose.model('Label', labelSchema); + + diff --git a/web_server/db/models/Mail.js b/web_server/db/models/Mail.js new file mode 100644 index 00000000..e21f01df --- /dev/null +++ b/web_server/db/models/Mail.js @@ -0,0 +1,39 @@ +const mongoose = require('mongoose'); + +const fileSchema = new mongoose.Schema( + { + name: String, + url: String, + size: Number, + type: String, + }, + { _id: false } +); + +const mailSchema = new mongoose.Schema( + { + id: { type: Number, unique: true, index: true }, + owner: { type: Number, required: true }, + from: { type: Number, required: true }, + sentTo: { type: [Number], default: [] }, + subject: { type: String, default: '' }, + body: { type: String, default: '' }, + labels: { type: [Number], default: [] }, + isRead: { type: Boolean, default: false }, + isStarred: { type: Boolean, default: false }, + isTrashed: { type: Boolean, default: false }, + isSpam: { type: Boolean, default: false }, + isDraft: { type: Boolean, default: false }, + files: { type: [fileSchema], default: [] }, + }, + { + timestamps: true, + collection: 'mails', + } +); + +mailSchema.index({ owner: 1, isDraft: 1, createdAt: -1 }); + +module.exports = mongoose.models.Mail || mongoose.model('Mail', mailSchema); + + diff --git a/web_server/db/models/User.js b/web_server/db/models/User.js new file mode 100644 index 00000000..8faee3c0 --- /dev/null +++ b/web_server/db/models/User.js @@ -0,0 +1,26 @@ +const mongoose = require('mongoose'); + +const userSchema = new mongoose.Schema( + { + id: { type: Number, unique: true, index: true }, + fullName: { type: String, required: true }, + mail: { + type: String, + required: true, + unique: true, + lowercase: true, + trim: true, + }, + password: { type: String, required: true }, + dateOfBirth: { type: String }, + image: { type: String }, + }, + { + timestamps: true, + collection: 'users', + } +); + +module.exports = mongoose.models.User || mongoose.model('User', userSchema); + + diff --git a/web_server/models/labels.js b/web_server/models/labels.js index 44af377b..f59ad6e8 100644 --- a/web_server/models/labels.js +++ b/web_server/models/labels.js @@ -1,136 +1,80 @@ -// Initial example labels -let labels = [ - { id: 1, name: 'work', owner: 1, parent: null }, - { id: 2, name: 'friends', owner: 1, parent: null }, - { id: 3, name: 'work', owner: 2, parent: null }, - { id: 4, name: 'work', owner: 3, parent: null } -]; +const Label = require('../db/models/Label'); +const Counter = require('../db/models/Counter'); -// Simple incrementing ID -let nextId = labels.length + 1; +async function nextSequence(sequenceName) { + const updated = await Counter.findByIdAndUpdate( + sequenceName, + { $inc: { seq: 1 } }, + { new: true, upsert: true } + ); + return updated.seq; +} -/** - * Return all labels owned by a specific user. - * @param {number} userId - ID of the user requesting the labels - * @returns {Array} Filtered label objects owned by the user - */ -function getAllLabels(userId) { - return labels.filter(l => l.owner == userId); +async function getAllLabels(userId) { + return Label.find({ owner: userId }).lean(); } -/** - * Find a label by its ID and owner - * @param {number} id - ID of label - * @param {number} owner - ID of the user - * @returns {object|null} Label object or null if not found - */ -function getLabelById(id,owner) { - return labels.find(l => l.id == id && l.owner == owner) || null; +async function getLabelById(id, owner) { + return Label.findOne({ id, owner }).lean(); } -/** - * Create a new top-level label (parent is null) - * @param {number} owner - User ID - * @param {string} name - Label name - * @returns {object} The created label - */ -function createNewLabel(owner, name) { - if (isDuplicateLabel(owner, name, null)) return null; - const lab = { id: nextId++, owner, name, parent: null }; - labels.push(lab); - return lab; +async function createNewLabel(owner, name) { + // unique index on {owner,name,parent:null} + const id = await nextSequence('labels'); + try { + const created = await Label.create({ id, owner, name, parent: null }); + return created.toObject(); + } catch (err) { + if (err && err.code === 11000) return null; // duplicate + throw err; + } } -/** - * Check if a label with the same name already exists - * for a given user and (optional) parent. - * Used to prevent duplicates on create/edit. - * @param {number} owner - User ID - * @param {string} name - Label name to check - * @param {number|null} parent - Parent label ID or null - * @param {number|null} excludeId - Optional: label ID to exclude from check (for editing) - * @returns {boolean} True if duplicate exists, false otherwise - */ -function isDuplicateLabel(owner, name, parent = null, excludeId = null) { - return labels.some(l => - l.owner === owner && - l.name === name && - l.parent === parent && - (excludeId === null || l.id !== excludeId) - ); +async function isDuplicateLabel(owner, name, parent = null, excludeId = null) { + const query = { owner, name, parent }; + if (excludeId !== null) { + query.id = { $ne: excludeId }; + } + const existing = await Label.findOne(query).lean(); + return !!existing; } -/** - * Create a sublabel under an existing label - * @param {number} owner - User ID - * @param {number} parent - Parent label ID - * @param {string} name - Sublabel name - * @returns {object|null} New label, or null if parent not found - */ -function createSublabel(owner, parent, name) { - // Check parent exists (could also check ownership) - const parentLabel = getLabelById(parent, owner); +async function createSublabel(owner, parent, name) { + const parentLabel = await Label.findOne({ id: parent, owner }).lean(); if (!parentLabel) return null; - - // Prevent duplicate sublabel names under the same parent for the same user - if (isDuplicateLabel(owner, name, parent)) throw new Error("409") - - const newLabel = { - id: nextId++, - name, - owner, - parent, - }; - labels.push(newLabel); - return newLabel; + if (await isDuplicateLabel(owner, name, parent)) throw new Error('409'); + const id = await nextSequence('labels'); + const created = await Label.create({ id, owner, name, parent }); + return created.toObject(); } -/** - * Rename a label by ID - * @param {number} id - * @param {string} newName - * @param {number} owner - User ID - * @returns {object|null} Updated label or null if not found - */ -function editLabelById(id, owner,newName) { - // Check parent exists (could also check ownership) - const Label = getLabelById(id, owner); - if (!Label) return null; - // Check if another label with the same name already exists - if (isDuplicateLabel(owner, newName, Label.parent, id)) { - throw new Error("409"); - } - // Rename the label - Label.name = newName; - return Label; +async function editLabelById(id, owner, newName) { + const existing = await Label.findOne({ id, owner }); + if (!existing) return null; + if (await isDuplicateLabel(owner, newName, existing.parent, id)) throw new Error('409'); + existing.name = newName; + await existing.save(); + return existing.toObject(); } -/** - * Delete a label by ID (does not cascade to sublabels) - * @param {number} id - * @param {number} owner - User ID - * @returns {boolean} True if deleted, false if not found - */ -function deleteLabelById(id,owner) { - const idx = labels.findIndex(l => l.id == id && l.owner == owner); - if (idx < 0) return false; - // Recursively delete children - const childLabels = labels.filter(l => l.parent == id && l.owner == owner); - childLabels.forEach(child => { - deleteLabelById(child.id, owner); - }); - - // Delete the label itself - labels.splice(idx, 1); +async function deleteLabelById(id, owner) { + const existing = await Label.findOne({ id, owner }).lean(); + if (!existing) return false; + // delete children recursively + const children = await Label.find({ parent: id, owner }).lean(); + for (const child of children) { + // eslint-disable-next-line no-await-in-loop + await deleteLabelById(child.id, owner); + } + await Label.deleteOne({ id, owner }); return true; } -// Export all functions module.exports = { getAllLabels, getLabelById, createNewLabel, createSublabel, editLabelById, - deleteLabelById + deleteLabelById, }; diff --git a/web_server/models/mails.js b/web_server/models/mails.js index db51d105..14cde3ce 100644 --- a/web_server/models/mails.js +++ b/web_server/models/mails.js @@ -417,13 +417,5 @@ const searchInInbox = (query, userId) => { }); } -module.exports = { - getUserMails, - saveDraft, - sendNewMail, - getMail, - editSentMail, - updateDraft, - deleteMail, - searchInInbox, -}; \ No newline at end of file +// Redirect to MongoDB-backed implementation +module.exports = require('./mails_db'); \ No newline at end of file diff --git a/web_server/models/mails_db.js b/web_server/models/mails_db.js new file mode 100644 index 00000000..a02a5805 --- /dev/null +++ b/web_server/models/mails_db.js @@ -0,0 +1,167 @@ +const Mail = require('../db/models/Mail'); +const Counter = require('../db/models/Counter'); + +async function nextSequence(sequenceName) { + const updated = await Counter.findByIdAndUpdate( + sequenceName, + { $inc: { seq: 1 } }, + { new: true, upsert: true } + ); + return updated.seq; +} + +function buildInboxMatch(owner, inboxType) { + const base = { owner }; + switch ((inboxType || 'all').toLowerCase()) { + // drafts aliases + case 'draft': + case 'drafts': + return { ...base, isDraft: true }; + case 'trash': + return { ...base, isTrashed: true }; + case 'spam': + return { ...base, isSpam: true }; + // starred aliases + case 'star': + case 'starred': + return { ...base, isStarred: true, isDraft: { $ne: true } }; + case 'sent': + return { ...base, from: owner, isDraft: { $ne: true } }; + // inbox aliases + case 'incoming': + case 'inbox': + return { ...base, isDraft: { $ne: true }, isTrashed: { $ne: true }, isSpam: { $ne: true }, from: { $ne: owner } }; + case 'all': + default: + return { ...base, isDraft: { $ne: true } }; + } +} + +async function getUserMails(userId, limit, inboxType, page) { + const match = buildInboxMatch(userId, inboxType); + const total = await Mail.countDocuments(match); + const paged = await Mail.find(match) + .sort({ createdAt: -1 }) + .skip(((page || 1) - 1) * (limit || 50)) + .limit(limit || 50) + .lean(); + return { paged, total }; +} + +async function saveDraft(owner, subject, body, sentToIds, files) { + const id = await nextSequence('mails'); + const created = await Mail.create({ + id, + owner, + from: owner, + sentTo: sentToIds || [], + subject: subject || '', + body: body || '', + isDraft: true, + files: files || [], + labels: [], + }); + return created.toObject(); +} + +async function sendNewMail(owner, subject, body, sentToIds, files) { + // create owner's copy (sent) + const ownerId = await nextSequence('mails'); + const ownerMail = await Mail.create({ + id: ownerId, + owner, + from: owner, + sentTo: sentToIds || [], + subject: subject || '', + body: body || '', + isDraft: false, + files: files || [], + labels: [], + }); + + // create recipient copies + if (Array.isArray(sentToIds)) { + for (const recipient of sentToIds) { + if (Number(recipient) === Number(owner)) continue; + const rid = await nextSequence('mails'); + // eslint-disable-next-line no-await-in-loop + await Mail.create({ + id: rid, + owner: recipient, + from: owner, + sentTo: sentToIds || [], + subject: subject || '', + body: body || '', + isDraft: false, + files: files || [], + labels: [], + }); + } + } + + return ownerMail.toObject(); +} + +async function getMail(id) { + return Mail.findOne({ id }).lean(); +} + +async function editSentMail(mailId, isRead, isStarred, isTrashed, isSpam, labelsIds) { + const mail = await Mail.findOne({ id: mailId }); + if (!mail) return 404; + if (typeof isRead === 'boolean') mail.isRead = isRead; + if (typeof isStarred === 'boolean') mail.isStarred = isStarred; + if (typeof isTrashed === 'boolean') mail.isTrashed = isTrashed; + if (typeof isSpam === 'boolean') mail.isSpam = isSpam; + if (Array.isArray(labelsIds)) mail.labels = labelsIds; + await mail.save(); + return mail.toObject(); +} + +async function updateDraft(mailId, subject, body, sentToIds, files) { + const mail = await Mail.findOne({ id: mailId, isDraft: true }); + if (!mail) return 404; + if (typeof subject === 'string') mail.subject = subject; + if (typeof body === 'string') mail.body = body; + if (Array.isArray(sentToIds)) mail.sentTo = sentToIds; + if (Array.isArray(files)) mail.files = files; + await mail.save(); + return mail.toObject(); +} + +async function deleteMail(owner, mailId) { + const mail = await Mail.findOne({ id: mailId }); + if (!mail) return 404; + if (Number(mail.owner) !== Number(owner)) return 400; + await Mail.deleteOne({ id: mailId }); + return true; +} + +async function searchInInbox(query, owner) { + const q = (query || '').trim(); + if (!q) return []; + const regex = new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'); + return Mail.find({ + owner, + isDraft: { $ne: true }, + $or: [ + { subject: regex }, + { body: regex }, + ], + }) + .sort({ createdAt: -1 }) + .lean(); +} + +module.exports = { + getUserMails, + saveDraft, + sendNewMail, + getMail, + editSentMail, + updateDraft, + deleteMail, + searchInInbox, +}; + + diff --git a/web_server/models/users.js b/web_server/models/users.js index 54289d95..77bdca57 100644 --- a/web_server/models/users.js +++ b/web_server/models/users.js @@ -162,13 +162,5 @@ const updateUser = (id, password, image) => { }; } -module.exports = { - getAllUsers, - getUserById, - getSafeUserById, - createUser, - userExist, - isAuthorizeUser, - getUserByMail, - updateUser -}; +// Redirect to MongoDB-backed implementation +module.exports = require('./users_db'); diff --git a/web_server/models/users_db.js b/web_server/models/users_db.js new file mode 100644 index 00000000..5c965b79 --- /dev/null +++ b/web_server/models/users_db.js @@ -0,0 +1,81 @@ +const User = require('../db/models/User'); +const Counter = require('../db/models/Counter'); + +async function nextSequence(sequenceName) { + const updated = await Counter.findByIdAndUpdate( + sequenceName, + { $inc: { seq: 1 } }, + { new: true, upsert: true } + ); + return updated.seq; +} + +async function getAllUsers() { + const users = await User.find({}).lean(); + return users.map(u => ({ + id: u.id, + fullName: u.fullName, + mail: u.mail, + password: u.password, + dateOfBirth: u.dateOfBirth, + image: u.image, + })); +} + +async function getUserById(id) { + const u = await User.findOne({ id }).lean(); + if (!u) return undefined; + return { id: u.id, fullName: u.fullName, mail: u.mail, password: u.password, dateOfBirth: u.dateOfBirth, image: u.image }; +} + +async function getSafeUserById(id) { + const u = await User.findOne({ id }).lean(); + if (!u) return undefined; + return { id: u.id, fullName: u.fullName, mail: u.mail, image: u.image, dateOfBirth: u.dateOfBirth }; +} + +async function getUserByMail(mail) { + const u = await User.findOne({ mail }).lean(); + if (!u) return undefined; + return { id: u.id, fullName: u.fullName, mail: u.mail }; +} + +async function userExist(mail) { + const count = await User.countDocuments({ mail }); + return count > 0; +} + +async function isAuthorizeUser(mail, password) { + const u = await User.findOne({ mail, password }).lean(); + if (!u) return undefined; + return { id: u.id, mail: u.mail, fullName: u.fullName, dateOfBirth: u.dateOfBirth, image: u.image }; +} + +async function createUser(fullName, mail, password, dateOfBirth, image) { + const id = await nextSequence('users'); + const created = await User.create({ id, fullName, mail, password, dateOfBirth, image }); + return { id: created.id, fullName: created.fullName, mail: created.mail, password: created.password, dateOfBirth: created.dateOfBirth, image: created.image }; +} + +async function updateUser(id, password, image) { + if (!id || (!password && !image)) return 400; + const update = {}; + if (image !== undefined) update.image = image; + if (password !== undefined) update.password = password; + const updated = await User.findOneAndUpdate({ id }, { $set: update }, { new: true }).lean(); + if (!updated) return 404; + return { id: updated.id, fullName: updated.fullName, mail: updated.mail, image: updated.image, dateOfBirth: updated.dateOfBirth }; +} + +module.exports = { + getAllUsers, + getUserById, + getSafeUserById, + createUser, + userExist, + isAuthorizeUser, + getUserByMail, + updateUser +}; + + diff --git a/web_server/package-lock.json b/web_server/package-lock.json index 0ad1fdb6..5e41f6a4 100644 --- a/web_server/package-lock.json +++ b/web_server/package-lock.json @@ -12,7 +12,32 @@ "cors": "^2.8.5", "dotenv": "^17.2.0", "express": "^5.1.0", - "jsonwebtoken": "^9.0.2" + "jsonwebtoken": "^9.0.2", + "mongoose": "^8.6.1" + } + }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.0.tgz", + "integrity": "sha512-zlayKCsIjYb7/IdfqxorK5+xUMyi4vOKcFy10wKJYc63NSdKI8mNME+uJqfatkPmOSMMUiojrL58IePKBm3gvQ==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" } }, "node_modules/accepts": { @@ -48,6 +73,15 @@ "node": ">=18" } }, + "node_modules/bson": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.20.1" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -516,6 +550,15 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/kareem": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", + "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -576,6 +619,12 @@ "node": ">= 0.8" } }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, "node_modules/merge-descriptors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", @@ -609,6 +658,105 @@ "node": ">= 0.6" } }, + "node_modules/mongodb": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.18.0.tgz", + "integrity": "sha512-fO5ttN9VC8P0F5fqtQmclAkgXZxbIkYRTUi1j8JO6IYwvamkhtYDilJr35jOPELR49zqCJgXZWwCtW7B+TM8vQ==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.1.9", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.0" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.2.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^14.1.0 || ^13.0.0" + } + }, + "node_modules/mongoose": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.18.0.tgz", + "integrity": "sha512-3TixPihQKBdyaYDeJqRjzgb86KbilEH07JmzV8SoSjgoskNTpa6oTBmDxeoF9p8YnWQoz7shnCyPkSV/48y3yw==", + "license": "MIT", + "dependencies": { + "bson": "^6.10.4", + "kareem": "2.6.3", + "mongodb": "~6.18.0", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=16.20.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", + "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", + "license": "MIT", + "dependencies": { + "debug": "4.x" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -697,6 +845,15 @@ "node": ">= 0.10" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -905,6 +1062,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sift": { + "version": "17.1.3", + "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", + "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", + "license": "MIT" + }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -923,6 +1095,18 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", @@ -955,6 +1139,28 @@ "node": ">= 0.8" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/web_server/package.json b/web_server/package.json index 5090cf97..4c267797 100644 --- a/web_server/package.json +++ b/web_server/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "main": "app.js", "scripts": { + "dev": "node app.js", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], @@ -14,6 +15,7 @@ "cors": "^2.8.5", "dotenv": "^17.2.0", "express": "^5.1.0", - "jsonwebtoken": "^9.0.2" + "jsonwebtoken": "^9.0.2", + "mongoose": "^8.6.1" } } diff --git a/web_server/routes/devLabels.js b/web_server/routes/devLabels.js new file mode 100644 index 00000000..1945ca33 --- /dev/null +++ b/web_server/routes/devLabels.js @@ -0,0 +1,53 @@ +const express = require('express'); +const Label = require('../db/models/Label'); +const Counter = require('../db/models/Counter'); + +const router = express.Router(); + +// GET /dev/labels/count - returns number of label documents +router.get('/count', async (req, res) => { + try { + const total = await Label.countDocuments(); + res.json({ total }); + } catch (err) { + res.status(500).json({ error: 'Failed to count labels' }); + } +}); + +// POST /dev/labels/seed - create a few labels for a given owner +router.post('/seed', async (req, res) => { + try { + const owner = Number(req.body.owner || 1); + const names = req.body.names || ['work', 'friends']; + const created = []; + for (const name of names) { + try { + // generate numeric id using counters via direct increment + // we rely on application-level creation through the model (controllers cover uniqueness) + const next = await Counter.findByIdAndUpdate('labels', { $inc: { seq: 1 } }, { new: true, upsert: true }); + const doc = await Label.create({ id: next.seq, owner, name, parent: null }); + created.push({ id: doc.id, name: doc.name }); + } catch (e) { + // ignore duplicates + } + } + res.json({ created }); + } catch (e) { + res.status(500).json({ error: 'Failed to seed labels' }); + } +}); + +// GET /dev/labels/list?owner=1 +router.get('/list', async (req, res) => { + try { + const owner = Number(req.query.owner || 1); + const labels = await Label.find({ owner }).sort({ id: 1 }).lean(); + res.json(labels.map(l => ({ id: l.id, name: l.name, parent: l.parent }))); + } catch (e) { + res.status(500).json({ error: 'Failed to list labels' }); + } +}); + +module.exports = router; + + diff --git a/web_server/routes/devMails.js b/web_server/routes/devMails.js new file mode 100644 index 00000000..e84fc887 --- /dev/null +++ b/web_server/routes/devMails.js @@ -0,0 +1,53 @@ +const express = require('express'); +const Mail = require('../db/models/Mail'); +const Counter = require('../db/models/Counter'); + +const router = express.Router(); + +// GET /dev/mails/count - returns number of mail documents +router.get('/count', async (req, res) => { + try { + const total = await Mail.countDocuments(); + res.json({ total }); + } catch (err) { + res.status(500).json({ error: 'Failed to count mails' }); + } +}); + +// GET /dev/mails/list?owner=1 +router.get('/list', async (req, res) => { + try { + const owner = Number(req.query.owner || 1); + const mails = await Mail.find({ owner }).sort({ createdAt: -1 }).lean(); + res.json( + mails.map(m => ({ id: m.id, subject: m.subject, isDraft: m.isDraft, labels: m.labels || [] })) + ); + } catch (e) { + res.status(500).json({ error: 'Failed to list mails' }); + } +}); + +// POST /dev/mails/seed - create a couple of mails for owner +router.post('/seed', async (req, res) => { + try { + const owner = Number(req.body.owner || 1); + const now = new Date(); + const samples = [ + { subject: 'Hello', body: 'Hi there', from: owner, sentTo: [owner], labels: [], isDraft: false }, + { subject: 'Draft 1', body: 'Work in progress', from: owner, sentTo: [owner], labels: [], isDraft: true } + ]; + const created = []; + for (const s of samples) { + const next = await Counter.findByIdAndUpdate('mails', { $inc: { seq: 1 } }, { new: true, upsert: true }); + const doc = await Mail.create({ id: next.seq, owner, ...s, createdAt: now, updatedAt: now }); + created.push({ id: doc.id, subject: doc.subject, isDraft: doc.isDraft }); + } + res.json({ created }); + } catch (e) { + res.status(500).json({ error: 'Failed to seed mails' }); + } +}); + +module.exports = router; + + diff --git a/web_server/routes/devUsers.js b/web_server/routes/devUsers.js new file mode 100644 index 00000000..6bb6db5c --- /dev/null +++ b/web_server/routes/devUsers.js @@ -0,0 +1,48 @@ +const express = require('express'); +const User = require('../db/models/User'); +const Counter = require('../db/models/Counter'); + +const router = express.Router(); + +router.get('/count', async (req, res) => { + try { + const total = await User.countDocuments(); + res.json({ total }); + } catch (e) { + res.status(500).json({ error: 'Failed to count users' }); + } +}); + +router.get('/list', async (req, res) => { + try { + const users = await User.find({}).sort({ createdAt: -1 }).lean(); + res.json(users.map(u => ({ id: u.id, fullName: u.fullName, mail: u.mail }))); + } catch (e) { + res.status(500).json({ error: 'Failed to list users' }); + } +}); + +router.post('/seed', async (req, res) => { + try { + const toCreate = req.body.users || [ + { fullName: 'Alice Example', mail: 'alice@example.com', password: 'pass', dateOfBirth: '1990-01-01', image: '' }, + { fullName: 'Bob Example', mail: 'bob@example.com', password: 'pass', dateOfBirth: '1992-02-02', image: '' }, + ]; + const created = []; + for (const u of toCreate) { + const updated = await Counter.findByIdAndUpdate('users', { $inc: { seq: 1 } }, { new: true, upsert: true }); + const id = updated.seq; + try { + const doc = await User.create({ id, ...u }); + created.push({ id: doc.id, mail: doc.mail }); + } catch (e) { + // ignore duplicates + } + } + res.json({ created }); + } catch (e) { + res.status(500).json({ error: 'Failed to seed users' }); + } +}); + +module.exports = router; diff --git a/web_server/routes/health.js b/web_server/routes/health.js new file mode 100644 index 00000000..e24fd263 --- /dev/null +++ b/web_server/routes/health.js @@ -0,0 +1,17 @@ +const express = require('express'); +const mongoose = require('mongoose'); + +const router = express.Router(); + +router.get('/', (req, res) => { + const mongoState = mongoose.connection.readyState === 1 ? 'connected' : 'disconnected'; + res.json({ + status: 'ok', + mongo: mongoState, + uptime: process.uptime() + }); +}); + +module.exports = router; + + diff --git a/web_server/utils/labels.js b/web_server/utils/labels.js index c7c7fce6..36410dbc 100644 --- a/web_server/utils/labels.js +++ b/web_server/utils/labels.js @@ -19,13 +19,12 @@ function convertLabelsToIds(userId, labelsObjects) { * @param labelsIds array of labels' ids * @returns {*} array of labels elements */ -function labelsToFullElement(userId, labelsIds) { - if (!labelsIds) - return []; - const objects = labelsIds - .map(id => Labels.getLabelById(id, userId)) - .filter(label => label); - return [...new Set(objects)]; +async function labelsToFullElement(userId, labelsIds) { + if (!labelsIds) return []; + const objects = await Promise.all( + labelsIds.map(async (id) => Labels.getLabelById(id, userId)) + ); + return [...new Set(objects.filter(Boolean))]; } /** @@ -33,11 +32,13 @@ function labelsToFullElement(userId, labelsIds) { * @param {Object} mail mail object * @returns {string[]} an array of lowercase label names for this mail. */ -const mailLabelNames = (userId, mail) => { - return (mail.labels || []) - .map(labelId => Labels.getLabelById(labelId, userId)) - .filter(label => label && label.name) - .map(label => label.name.toLowerCase()); +const mailLabelNames = async (userId, mail) => { + const objs = await Promise.all( + (mail.labels || []).map((labelId) => Labels.getLabelById(labelId, userId)) + ); + return objs + .filter((label) => label && label.name) + .map((label) => label.name.toLowerCase()); }; module.exports = {convertLabelsToIds, labelsToFullElement, mailLabelNames} \ No newline at end of file diff --git a/web_server/utils/users.js b/web_server/utils/users.js index ca1a9200..8206449e 100644 --- a/web_server/utils/users.js +++ b/web_server/utils/users.js @@ -5,10 +5,14 @@ const Users = require('../models/users'); * @param addresses of mails of users * @returns {number[]} array of user ids */ -function convertMailsToIds(addresses) { - return addresses.map(mailAdd => { - return Users.getUserByMail(mailAdd).id; - }); +async function convertMailsToIds(addresses) { + const results = await Promise.all( + (addresses || []).map(async (mailAdd) => { + const u = await Users.getUserByMail(mailAdd); + return u ? u.id : undefined; + }) + ); + return results.filter((v) => typeof v === 'number'); } /** @@ -16,10 +20,11 @@ function convertMailsToIds(addresses) { * @param {number[]} usersIds array of users' ids * @returns {*} array of safe user elements */ -const usersToFullElement = (usersIds) => { - return usersIds.map(uid => { - return Users.getSafeUserById(uid); - }); +const usersToFullElement = async (usersIds) => { + const results = await Promise.all( + (usersIds || []).map(async (uid) => Users.getSafeUserById(uid)) + ); + return results.filter(Boolean); } /** @@ -30,14 +35,14 @@ const usersToFullElement = (usersIds) => { * - each recipient name * - each recipient email */ -const mailUserFields = (mail) => { - const sender = Users.getSafeUserById(mail.from); - const recipients = (mail.sentTo || []) - .map(id => Users.getSafeUserById(id)); - const all = [sender, ...recipients]; - return all.flatMap(u => [u.name, u.mail]) +const mailUserFields = async (mail) => { + const sender = await Users.getSafeUserById(mail.from); + const recipients = await Promise.all((mail.sentTo || []).map((id) => Users.getSafeUserById(id))); + const all = [sender, ...recipients].filter(Boolean); + return all + .flatMap((u) => [u.name, u.mail]) .filter(Boolean) - .map(s => s.toLowerCase()); + .map((s) => s.toLowerCase()); };