Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
/bower_components

# misc
/scripts
# /scripts
/.sass-cache
/connect.lock
/coverage/*
Expand All @@ -35,4 +35,4 @@ patch-mongo.js
skip-db.js
stub-db.js
stub-db-strong.js
scripts/
# scripts/
15,452 changes: 0 additions & 15,452 deletions package-lock 2.json

This file was deleted.

426 changes: 213 additions & 213 deletions package-lock.json

Large diffs are not rendered by default.

188 changes: 150 additions & 38 deletions src/controllers/userStateController.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,38 @@ const mongoose = require('mongoose');
const UserStateCatalog = require('../models/userStateCatalog');
const UserStateSelection = require('../models/userStateSelection');

const ALLOWED_COLORS = ['red', 'blue', 'purple', 'green', 'orange'];
const ALLOWED_COLORS = [
'#3498db',
'#27ae60',
'#9b59b6',
'#e67e22',
'#e74c3c',
'#16a085',
'#2c3e50',
'#e91e8c',
'#f1c40f',
'#3f51b5',
'#00bcd4',
'#795548',
'#8bc34a',
'#673ab7',
'#607d8b',
];

const slugify = (s) =>
s
.replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]/gu, '')
.toLowerCase()
.replaceAll(/[^a-z0-9\s]+/gu, '')
.trim()
.replaceAll(/\s+/gu, '-');

const generateKey = (label) => {
const base = slugify(label);
const suffix = Math.random().toString(36).slice(2, 6);
return base ? `${base}-${suffix}` : suffix;
};

function checkManage(req) {
const requestor = req.body?.requestor || {};
return (
Expand Down Expand Up @@ -48,52 +71,57 @@ const listCatalog = async (req, res) => {
}
};

function sanitizeEmoji(emoji) {
if (typeof emoji !== 'string') return null;
return [...emoji]
.filter((c) => /\p{Emoji_Presentation}|\p{Extended_Pictographic}/u.test(c))
.join('')
.slice(0, 2);
}

const createCatalog = async (req, res) => {
if (!checkManage(req)) return res.status(403).json({ error: 'Forbidden' });

try {
const { label, color } = req.body || {};
const { label, color, emoji } = req.body || {};
if (!label || typeof label !== 'string') {
return res.status(400).json({ error: 'label is required' });
}
if (label.length > 30) {
return res.status(400).json({ error: 'label must be ≀ 30 chars' });
}

const key = slugify(label);
if (!key) return res.status(400).json({ error: 'label produced empty key' });

const escapedLabel = escapeRegex(label);
const exists = await UserStateCatalog.findOne({
$or: [{ key }, { label: { $regex: `^${escapedLabel}$`, $options: 'i' } }],
});
if (exists) {
if (!exists.isActive) {
exists.isActive = true;
await exists.save();
return res.status(201).json({ item: exists });
}
return res.status(409).json({ error: 'label/key already exists' });
}
const key = generateKey(label);

const max = await UserStateCatalog.findOne().sort({ order: -1 }).lean();
const nextOrder = max ? max.order + 1 : 0;

// Fix L87: break taint chain completely
const safeColor = ALLOWED_COLORS.includes(color) ? color : ALLOWED_COLORS[nextOrder % 5];
const safeKey = key
.split('')
.filter((c) => /[a-z0-9-]/u.test(c))
.join('');
const safeLabel = label
.split('')
.filter((c) => c.codePointAt(0) >= 32 && c.codePointAt(0) <= 126)
const safeLabel = [...label]
.filter((c) => c.codePointAt(0) >= 32 && c.codePointAt(0) !== 127)
.join('')
.slice(0, 30);

const safeColor = ALLOWED_COLORS.includes(color)
? color
: ALLOWED_COLORS[nextOrder % ALLOWED_COLORS.length];

const safeEmoji = sanitizeEmoji(emoji) ?? '';

const escapedLabel = escapeRegex(safeLabel);
const clash = await UserStateCatalog.findOne({
label: { $regex: `^${escapedLabel}$`, $options: 'i' },
emoji: safeEmoji,
isActive: true,
}).lean();

if (clash) {
return res.status(409).json({ error: 'A state with this label and emoji already exists' });
}

const item = await UserStateCatalog.create({
key: String(safeKey),
key: String(key),
label: String(safeLabel),
emoji: String(safeEmoji),
color: String(safeColor),
order: Number(nextOrder),
isActive: true,
Expand Down Expand Up @@ -138,35 +166,62 @@ const reorderCatalog = async (req, res) => {
}
};

async function checkLabelClash(trimmed, itemId, resolvedEmoji) {
const escapedTrimmed = escapeRegex(trimmed);
return UserStateCatalog.findOne({
_id: { $ne: itemId },
label: { $regex: `^${escapedTrimmed}$`, $options: 'i' },
emoji: resolvedEmoji,
isActive: true,
}).lean();
}

async function handleIsActive(item, isActive, key) {
item.isActive = isActive;
if (isActive === false) {
await UserStateSelection.updateMany(
{ 'stateIndicators.key': key },
{ $pull: { stateIndicators: { key } } },
);
}
}

const updateCatalog = async (req, res) => {
if (!checkManage(req)) return res.status(403).json({ error: 'Forbidden' });

const key = sanitizeKey(req.params.key);
if (!key) return res.status(400).json({ error: 'invalid key' });

const { label, isActive } = req.body || {};
const { label, color, emoji, isActive } = req.body || {};
const safeEmoji = sanitizeEmoji(emoji);

try {
// Fix L143: explicitly reassign sanitized key before DB query
const safeKeyParam = String(key);
const item = await UserStateCatalog.findOne({ key: { $eq: safeKeyParam } });
const item = await UserStateCatalog.findOne({ key: { $eq: String(key) } });
if (!item) return res.status(404).json({ error: 'not found' });

if (typeof label === 'string') {
const trimmed = label.trim();
if (!trimmed) return res.status(400).json({ error: 'label cannot be empty' });
if (trimmed.length > 30) return res.status(400).json({ error: 'label must be ≀ 30 chars' });

const escapedTrimmed = escapeRegex(trimmed);
const clash = await UserStateCatalog.findOne({
_id: { $ne: item._id },
label: { $regex: `^${escapedTrimmed}$`, $options: 'i' },
}).lean();
if (clash) return res.status(409).json({ error: 'label already exists' });
const resolvedEmoji = safeEmoji === null ? item.emoji : safeEmoji;
const clash = await checkLabelClash(trimmed, item._id, resolvedEmoji);
if (clash)
return res.status(409).json({ error: 'A state with this label and emoji already exists' });

item.label = trimmed;
}

if (typeof color === 'string' && ALLOWED_COLORS.includes(color)) {
item.color = color;
}

if (typeof emoji === 'string') {
item.emoji = safeEmoji;
}

if (typeof isActive === 'boolean') {
item.isActive = isActive;
await handleIsActive(item, isActive, key);
}

await item.save();
Expand All @@ -176,6 +231,22 @@ const updateCatalog = async (req, res) => {
}
};

const getCatalogItemUsage = async (req, res) => {
if (!checkManage(req)) return res.status(403).json({ error: 'Forbidden' });

const { key } = req.params;
if (!key) return res.status(400).json({ error: 'key is required' });

try {
const count = await UserStateSelection.countDocuments({
'stateIndicators.key': key,
});
return res.json({ key, count });
} catch (err) {
return res.status(500).json({ error: 'db error', details: err.message });
}
};

const getUserSelections = async (req, res) => {
// Fix L172: validate userId as ObjectId β€” SonarCloud safe
const userId = parseUserId(req.params.userId);
Expand Down Expand Up @@ -240,11 +311,52 @@ const setUserSelections = async (req, res) => {
}
};

const getBatchUserSelections = async (req, res) => {
const { userIds } = req.body || {};

if (!Array.isArray(userIds) || userIds.length === 0) {
return res.status(400).json({ error: 'userIds must be a non-empty array' });
}

// Most users (managers, mentors) have small teams (10-50 members) so this is fast.
// Owners/Admins may have 1000+ users β€” pagination should be implemented for that case - will handle later on
if (userIds.length > 3000) {
return res
.status(400)
.json({ error: `too many userIds (max 300), received: ${userIds.length}` });
}

// Validate each as a proper ObjectId before hitting the DB
const validIds = userIds.map((id) => parseUserId(id)).filter(Boolean);
if (validIds.length === 0) {
return res.status(400).json({ error: 'no valid userIds provided' });
}

try {
const docs = await UserStateSelection.find(
{ userId: { $in: validIds } },
'userId stateIndicators',
).lean();

// Shape into { [userId]: stateIndicators[] } for easy lookup on the frontend
const selections = {};
for (const doc of docs) {
selections[String(doc.userId)] = doc.stateIndicators || [];
}

return res.json({ selections });
} catch (batchError) {
return res.status(500).json({ error: 'db error', details: batchError.message });
}
};

module.exports = {
listCatalog,
createCatalog,
reorderCatalog,
updateCatalog,
getCatalogItemUsage,
getUserSelections,
setUserSelections,
getBatchUserSelections,
};
3 changes: 2 additions & 1 deletion src/models/userStateCatalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ const userStateCatalogSchema = new mongoose.Schema(
{
key: { type: String, required: true, unique: true, trim: true },
label: { type: String, required: true, trim: true },
color: { type: String, default: 'blue' },
emoji: { type: String, default: '', trim: true },
color: { type: String, default: '#3498db' },
order: { type: Number, default: 0 },
isActive: { type: Boolean, default: true },
},
Expand Down
4 changes: 4 additions & 0 deletions src/routes/userState.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,22 @@ const {
createCatalog,
reorderCatalog,
updateCatalog,
getCatalogItemUsage,
getUserSelections,
setUserSelections,
getBatchUserSelections,
} = require('../controllers/userStateController');

// Catalog routes
router.get('/catalog', listCatalog);
router.post('/catalog', createCatalog);
router.put('/catalog/reorder', reorderCatalog);
router.patch('/catalog/:key', updateCatalog);
router.get('/catalog/:key/usage', getCatalogItemUsage);

// User selection routes
router.get('/selection/:userId', getUserSelections);
router.put('/selection/:userId', setUserSelections);
router.post('/selections/batch', getBatchUserSelections);

module.exports = router;
52 changes: 52 additions & 0 deletions src/scripts/resetUserCatalog.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// IMPORTANT - PLEASE DO NOT RUN THIS UNLESS EXPLICITLY ASKED TO!!
const mongoose = require('mongoose');
require('dotenv').config();
const readline = require('node:readline');
const UserStateCatalog = require('../models/userStateCatalog');

const RESET_PASSWORD = process.env.RESET_USERCATALOG_PASSWORD;

if (!RESET_PASSWORD) {
console.error('❌ RESET_USERCATALOG_PASSWORD env variable is not set. Aborting.');
process.exit(1);
}

function prompt(question) {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer);
});
});
}

async function reset() {
const input = await prompt('Enter reset password: ');

if (input.trim() !== RESET_PASSWORD) {
console.error('❌ Incorrect password. Aborting.');
process.exit(1);
}

const confirm = await prompt(
`⚠️ This will permanently delete ALL catalog entries. Type "yes" to confirm: `,
);

if (confirm.trim().toLowerCase() !== 'yes') {
console.log('Aborted.');
process.exit(0);
}

await mongoose.connect(process.env.MONGO_URI);
const result = await UserStateCatalog.deleteMany({});
console.log(`βœ“ Deleted ${result.deletedCount} catalog entries`);
await mongoose.disconnect();
}

reset().catch((err) => {
console.error(err);
process.exit(1);
});

// PS - PASSWORD PROTECTED, REACH OUT TO DIYA FOR PASSWORD
Loading
Loading