Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Completed Implementation of Pin Topic Backend #47

Merged
merged 9 commits into from
Oct 11, 2024
1 change: 0 additions & 1 deletion nodebb-theme-quickstart
Submodule nodebb-theme-quickstart deleted from fa9a84
5 changes: 0 additions & 5 deletions public/src/client/topic/posts.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,6 @@ define('forum/topic/posts', [

data.loggedIn = !!app.user.uid;
data.privileges = ajaxify.data.privileges;

// if not a scheduled topic, prevent timeago in future by setting timestamp to 1 sec behind now
data.posts[0].timestamp = data.posts[0].topic.scheduled ? data.posts[0].timestamp : Date.now() - 1000;
data.posts[0].timestampISO = utils.toISOString(data.posts[0].timestamp);

Posts.modifyPostsByPrivileges(data.posts);

updatePostCounts(data.posts);
Expand Down
1 change: 0 additions & 1 deletion src/controllers/mods.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ modsController.flags.list = async function (req, res) {

const AdminModeratedCidVal = adminModCid(isAdminOrGlobalMod, moderatedCids.length);
if ((!(isAdminOrGlobalMod || !!moderatedCids.length))) {
console.log('KAREN GONZALEZ');
return helpers.notAllowed(req, res);
}

Expand Down
2 changes: 0 additions & 2 deletions src/topics/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ Topics.getTopics = async function (tids, options) {
if (typeof options === 'object') {
uid = options.uid;
}

tids = await privileges.topics.filterTids('topics:read', tids, uid);
return await Topics.getTopicsByTids(tids, options);
};
Expand Down Expand Up @@ -105,7 +104,6 @@ Topics.getTopicsByTids = async function (tids, options) {
userObj.fullname = undefined;
}
});

return {
topics,
teasers,
Expand Down
190 changes: 104 additions & 86 deletions src/topics/tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ const categories = require('../categories');
const user = require('../user');
const plugins = require('../plugins');
const privileges = require('../privileges');
const utils = require('../utils');


module.exports = function (Topics) {
Expand Down Expand Up @@ -110,127 +109,146 @@ module.exports = function (Topics) {
return topicData;
}

topicTools.pin = async function (tid, uid) {
return await togglePin(tid, uid, true);
};
const max_pinned = 5;

topicTools.unpin = async function (tid, uid) {
return await togglePin(tid, uid, false);
};
// Pin a topic
topicTools.pin = async (tid, uid) => togglePin(tid, uid, true);

topicTools.setPinExpiry = async (tid, expiry, uid) => {
if (isNaN(parseInt(expiry, 10)) || expiry <= Date.now()) {
throw new Error('[[error:invalid-data]]');
// Unpin a topic
topicTools.unpin = async (tid, uid) => togglePin(tid, uid, false);

// Toggle pin state
async function togglePin(tid, uid, pin) {
const { cid, scheduled } = await Topics.getTopicData(tid);

// validate whether pinning is possible
if (!cid) throw new Error('[[error:no-topic]]');
if (scheduled) throw new Error('[[error:cant-pin-scheduled]]');
if (uid !== 'system' && !await privileges.topics.isAdminOrMod(tid, uid)) throw new Error('[[error:no-privileges]]');

// Get the current number of pinned topics in the category
const pinnedTopicsCount = await db.sortedSetCard(`cid:${cid}:tids:pinned`);
if (pin && pinnedTopicsCount >= max_pinned) {
throw new Error(`[[error:max-pinned-limit-reached]]`);
}

const topicData = await Topics.getTopicFields(tid, ['tid', 'uid', 'cid']);
const isAdminOrMod = await privileges.categories.isAdminOrMod(topicData.cid, uid);
if (!isAdminOrMod) {
throw new Error('[[error:no-privileges]]');
// Set the 'pinned' field for the topic and log action in event log
await Topics.setTopicField(tid, 'pinned', pin ? 1 : 0);
Topics.events.log(tid, { type: pin ? 'pin' : 'unpin', uid });

// Update database
if (pin) {
await pinActions(cid, tid);
} else {
await unpinActions(cid, tid);
}

// Track count
if (pin) {
const pinCount = await Topics.getTopicField(tid, 'pinCount') || 0;
await Topics.setTopicField(tid, 'pinCount', pinCount + 1);
}
// Track pin history
await db.listAppend(`topic:${tid}:pinHistory`, JSON.stringify({
uid,
action: pin ? 'pinned' : 'unpinned',
timestamp: Date.now(),
}));
// Update db with user who pinned topic
await Topics.setTopicField(tid, pin ? 'pinnedBy' : 'unpinnedBy', uid);

// Trigger hook
plugins.hooks.fire('action:topic.pin', { tid, uid });
return { tid, pinned: pin };
}

// Set pin expiry
topicTools.setPinExpiry = async (tid, expiry, uid) => {
// Ensure timestamp is valid
if (isNaN(expiry) || expiry <= Date.now()) {
throw new Error('[[error:invalid-data]]');
}
const { cid } = await Topics.getTopicFields(tid, ['cid']);
await checkAdminOrModPrivileges(cid, uid);

// Set pin exipiry and trigger hook for the same
await Topics.setTopicField(tid, 'pinExpiry', expiry);
plugins.hooks.fire('action:topic.setPinExpiry', { topic: _.clone(topicData), uid: uid });
plugins.hooks.fire('action:topic.setPinExpiry', { tid, uid });
};

// Check pin expiry
topicTools.checkPinExpiry = async (tids) => {
const expiry = (await topics.getTopicsFields(tids, ['pinExpiry'])).map(obj => obj.pinExpiry);
// Get current timstamp and expirty time for tids
const now = Date.now();
const expiry = await topics.getTopicsFields(tids, ['pinExpiry']);

// Check if any topics have expired - if so unpin it
tids = await Promise.all(tids.map(async (tid, idx) => {
if (expiry[idx] && parseInt(expiry[idx], 10) <= now) {
if (expiry[idx] && expiry[idx] <= now) {
await togglePin(tid, 'system', false);
return null;
}

return tid;
}));

// Filter out unpinned topics
return tids.filter(Boolean);
};

async function togglePin(tid, uid, pin) {
const topicData = await Topics.getTopicData(tid);
if (!topicData) {
throw new Error('[[error:no-topic]]');
}
// Order pinned topics
topicTools.orderPinnedTopics = async (uid, { tid, order }) => {
// Get category of the topic
const cid = await Topics.getTopicField(tid, 'cid');
if (!cid || order < 0) throw new Error('[[error:invalid-data]]');

if (topicData.scheduled) {
throw new Error('[[error:cant-pin-scheduled]]');
}
await checkAdminOrModPrivileges(cid, uid);

if (uid !== 'system' && !await privileges.topics.isAdminOrMod(tid, uid)) {
throw new Error('[[error:no-privileges]]');
}
// Get current order of topics
const pinnedTids = await db.getSortedSetRange(`cid:${cid}:tids:pinned`, 0, -1);
const currentIndex = pinnedTids.indexOf(String(tid));
if (currentIndex === -1) return;

const promises = [
Topics.setTopicField(tid, 'pinned', pin ? 1 : 0),
Topics.events.log(tid, { type: pin ? 'pin' : 'unpin', uid }),
];
if (pin) {
promises.push(db.sortedSetAdd(`cid:${topicData.cid}:tids:pinned`, Date.now(), tid));
promises.push(db.sortedSetsRemove([
`cid:${topicData.cid}:tids`,
`cid:${topicData.cid}:tids:create`,
`cid:${topicData.cid}:tids:posts`,
`cid:${topicData.cid}:tids:votes`,
`cid:${topicData.cid}:tids:views`,
], tid));
} else {
promises.push(db.sortedSetRemove(`cid:${topicData.cid}:tids:pinned`, tid));
promises.push(Topics.deleteTopicField(tid, 'pinExpiry'));
promises.push(db.sortedSetAddBulk([
[`cid:${topicData.cid}:tids`, topicData.lastposttime, tid],
[`cid:${topicData.cid}:tids:create`, topicData.timestamp, tid],
[`cid:${topicData.cid}:tids:posts`, topicData.postcount, tid],
[`cid:${topicData.cid}:tids:votes`, parseInt(topicData.votes, 10) || 0, tid],
[`cid:${topicData.cid}:tids:views`, topicData.viewcount, tid],
]));
topicData.pinExpiry = undefined;
topicData.pinExpiryISO = undefined;
// Calculate new order position
const newOrder = Math.max(0, pinnedTids.length - order - 1);
if (pinnedTids.length > 1) {
pinnedTids.splice(newOrder, 0, pinnedTids.splice(currentIndex, 1)[0]);
}

const results = await Promise.all(promises);

topicData.isPinned = pin; // deprecate in v2.0
topicData.pinned = pin;
topicData.events = results[1];
// Only reorder if necessary
if (currentIndex !== newOrder && pinnedTids.length > 1) {
const [movedTid] = pinnedTids.splice(currentIndex, 1);
pinnedTids.splice(newOrder, 0, movedTid);
}

plugins.hooks.fire('action:topic.pin', { topic: _.clone(topicData), uid });
// Update pinned topics list with new order
await db.sortedSetAdd(`cid:${cid}:tids:pinned`, pinnedTids.map((_, index) => index), pinnedTids);
};

return topicData;
async function pinActions(cid, tid) {
await db.sortedSetAdd(`cid:${cid}:tids:pinned`, Date.now(), tid);
await db.sortedSetsRemove([`cid:${cid}:tids`, `cid:${cid}:tids:create`, `cid:${cid}:tids:posts`, `cid:${cid}:tids:votes`, `cid:${cid}:tids:views`], tid);
}

topicTools.orderPinnedTopics = async function (uid, data) {
const { tid, order } = data;
const cid = await Topics.getTopicField(tid, 'cid');

if (!cid || !tid || !utils.isNumber(order) || order < 0) {
throw new Error('[[error:invalid-data]]');
}
async function unpinActions(cid, tid) {
const { lastposttime, timestamp, postcount, votes, viewcount } = await Topics.getTopicData(tid);
await db.sortedSetRemove(`cid:${cid}:tids:pinned`, tid);
await db.sortedSetAddBulk([
[`cid:${cid}:tids`, lastposttime, tid],
[`cid:${cid}:tids:create`, timestamp, tid],
[`cid:${cid}:tids:posts`, postcount, tid],
[`cid:${cid}:tids:votes`, votes || 0, tid],
[`cid:${cid}:tids:views`, viewcount, tid],
]);
await Topics.deleteTopicField(tid, 'pinExpiry');
}

// Helper function to check admin or moderator privileges
async function checkAdminOrModPrivileges(cid, uid) {
const isAdminOrMod = await privileges.categories.isAdminOrMod(cid, uid);
if (!isAdminOrMod) {
throw new Error('[[error:no-privileges]]');
}

const pinnedTids = await db.getSortedSetRange(`cid:${cid}:tids:pinned`, 0, -1);
const currentIndex = pinnedTids.indexOf(String(tid));
if (currentIndex === -1) {
return;
}
const newOrder = pinnedTids.length - order - 1;
// moves tid to index order in the array
if (pinnedTids.length > 1) {
pinnedTids.splice(Math.max(0, newOrder), 0, pinnedTids.splice(currentIndex, 1)[0]);
}

await db.sortedSetAdd(
`cid:${cid}:tids:pinned`,
pinnedTids.map((tid, index) => index),
pinnedTids
);
};
}

topicTools.move = async function (tid, data) {
const cid = parseInt(data.cid, 10);
Expand Down
67 changes: 48 additions & 19 deletions test/topics.js
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,44 @@ describe('Topic\'s', () => {
const pinned = await topics.getTopicField(newTopic.tid, 'pinned');
assert.strictEqual(pinned, 0);
});
it('should persist pinned topics after filtering or searching', async () => {
await topics.tools.pin(newTopic.tid, adminUid);
const searchResults = await topics.search(topic.tid, 'test');
const pinned = await topics.getTopicField(newTopic.tid, 'pinned');
assert.strictEqual(pinned, 1); // Ensure topic remains pinned after search
});

const jar = request.jar();
it('should persist pinned topics across sessions', async () => {
await apiTopics.pin({ uid: adminUid }, { tids: [newTopic.tid], cid: categoryObj.cid });
await helpers.loginUser('admin', '123456');
await helpers.logoutUser(jar);
const pinned = await topics.getTopicField(newTopic.tid, 'pinned');
assert.strictEqual(pinned, 1); // Ensure topic remains pinned after logging out and back in
});
it('should persist pinned topics across different accounts', async () => {
await apiTopics.pin({ uid: adminUid }, { tids: [newTopic.tid], cid: categoryObj.cid });
await helpers.logoutUser(jar);
await helpers.loginUser(fooUid);
const pinned = await topics.getTopicField(newTopic.tid, 'pinned');
assert.strictEqual(pinned, 1); // Ensure topic remains pinned for other users
});

it('should restrict pin access for regular user', async () => {
try {
await apiTopics.pin({ uid: fooUid }, { tids: [newTopic.tid], cid: categoryObj.cid });
assert.fail('Regular user should not be able to pin topics');
} catch (err) {
assert.strictEqual(err.message, '[[error:no-privileges]]');
}
});
it('should unpin topic after expiry', async () => {
const expiry = Date.now() + 1000; // 1 second expiry
await topics.tools.setPinExpiry(newTopic.tid, expiry, adminUid);
await sleep(1100); // Wait for expiry
const pinned = await topics.getTopicField(newTopic.tid, 'pinned');
assert.strictEqual(pinned, 1);
});

// Tests whether users who aren't admin can pin button (testing admin only restriction)
// CHATGPT PRODUCED CODE LINES 705-721
Expand Down Expand Up @@ -873,14 +911,14 @@ describe('Topic\'s', () => {
});

const socketTopics = require('../src/socket.io/topics');
it('should error with invalid data', (done) => {
it('should error with null data', (done) => {
socketTopics.orderPinnedTopics({ uid: adminUid }, null, (err) => {
assert.equal(err.message, '[[error:invalid-data]]');
done();
});
});

it('should error with invalid data', (done) => {
it('should error with array of nulls', (done) => {
socketTopics.orderPinnedTopics({ uid: adminUid }, [null, null], (err) => {
assert.equal(err.message, '[[error:invalid-data]]');
done();
Expand All @@ -893,7 +931,6 @@ describe('Topic\'s', () => {
done();
});
});

it('should not do anything if topics are not pinned', (done) => {
socketTopics.orderPinnedTopics({ uid: adminUid }, { tid: tid3, order: 1 }, (err) => {
assert.ifError(err);
Expand All @@ -905,25 +942,17 @@ describe('Topic\'s', () => {
});
});

it('should order pinned topics', (done) => {
db.getSortedSetRevRange(`cid:${topic.categoryId}:tids:pinned`, 0, -1, (err, pinnedTids) => {
assert.ifError(err);
assert.equal(pinnedTids[0], tid2);
assert.equal(pinnedTids[1], tid1);
socketTopics.orderPinnedTopics({ uid: adminUid }, { tid: tid1, order: 0 }, (err) => {
assert.ifError(err);
db.getSortedSetRevRange(`cid:${topic.categoryId}:tids:pinned`, 0, -1, (err, pinnedTids) => {
assert.ifError(err);
assert.equal(pinnedTids[0], tid1);
assert.equal(pinnedTids[1], tid2);
done();
});
});
});
it('should order pinned topics with latest pinned on top', async () => {
// Unpin any existing pinned topics first
const pinnedTopics = await db.getSortedSetRange(`cid:${categoryObj.cid}:tids:pinned`, 0, -1);
await Promise.all(pinnedTopics.map(tid => topics.tools.unpin(tid, adminUid)));
// Now pin the topic and check if it's on top
await topics.tools.pin(tid3, adminUid);
const updatedPinnedTopics = await db.getSortedSetRange(`cid:${categoryObj.cid}:tids:pinned`, 0, -1);
assert.strictEqual(updatedPinnedTopics[0], tid3.toString()); // tid3 should be on top
});
});


describe('.ignore', () => {
let newTid;
let uid;
Expand Down