mirror of
https://github.com/NodeBB/NodeBB.git
synced 2025-10-26 16:46:12 +01:00
feat: scheduled topics (#9399)
* feat: scheduled topics * refactor: linting fixes * fix: tests * fix(test): race condition * fix: make a single request
This commit is contained in:
@@ -25,6 +25,7 @@
|
||||
"access-topics": "Access Topics",
|
||||
"create-topics": "Create Topics",
|
||||
"reply-to-topics": "Reply to Topics",
|
||||
"schedule-topics": "Schedule Topics",
|
||||
"tag-topics": "Tag Topics",
|
||||
"edit-posts": "Edit Posts",
|
||||
"view-edit-history": "View Edit History",
|
||||
|
||||
@@ -104,6 +104,13 @@
|
||||
"guest-upload-disabled": "Guest uploading has been disabled",
|
||||
"cors-error": "Unable to upload image due to misconfigured CORS",
|
||||
|
||||
"scheduling-to-past": "Please select a date in the future.",
|
||||
"invalid-schedule-date": "Please enter a valid date and time.",
|
||||
"cant-pin-scheduled": "Scheduled topics cannot be (un)pinned.",
|
||||
"cant-merge-scheduled": "Scheduled topics cannot be merged.",
|
||||
"cant-move-posts-to-scheduled": "Can't move posts to a scheduled topic.",
|
||||
"cant-move-from-scheduled-to-existing": "Can't move posts from a scheduled topic to an existing topic.",
|
||||
|
||||
"already-bookmarked": "You have already bookmarked this post",
|
||||
"already-unbookmarked": "You have already unbookmarked this post",
|
||||
|
||||
|
||||
@@ -62,6 +62,11 @@
|
||||
"composer.zen_mode": "Zen Mode",
|
||||
"composer.select_category": "Select a category",
|
||||
"composer.textarea.placeholder": "Enter your post content here, drag and drop images",
|
||||
"composer.schedule-for": "Schedule topic for",
|
||||
"composer.schedule-date": "Date",
|
||||
"composer.schedule-time": "Time",
|
||||
"composer.cancel-scheduling": "Cancel Scheduling",
|
||||
"composer.set-schedule-date": "Set Date",
|
||||
|
||||
|
||||
"bootbox.ok": "OK",
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"locked": "Locked",
|
||||
"pinned": "Pinned",
|
||||
"pinned-with-expiry": "Pinned until %1",
|
||||
"scheduled": "Scheduled",
|
||||
"moved": "Moved",
|
||||
"moved-from": "Moved from %1",
|
||||
"copy-ip": "Copy IP",
|
||||
@@ -153,6 +154,7 @@
|
||||
"composer.handle_placeholder": "Enter your name/handle here",
|
||||
"composer.discard": "Discard",
|
||||
"composer.submit": "Submit",
|
||||
"composer.schedule": "Schedule",
|
||||
"composer.replying_to": "Replying to %1",
|
||||
"composer.new_topic": "New Topic",
|
||||
"composer.editing": "Editing",
|
||||
|
||||
@@ -76,6 +76,13 @@ PostObject:
|
||||
type: string
|
||||
deleted:
|
||||
type: number
|
||||
scheduled:
|
||||
type: number
|
||||
timestamp:
|
||||
type: number
|
||||
timestampISO:
|
||||
type: string
|
||||
description: An ISO 8601 formatted date string (complementing `timestamp`)
|
||||
postcount:
|
||||
type: number
|
||||
mainPid:
|
||||
|
||||
@@ -213,6 +213,8 @@ TopicObjectSlim:
|
||||
type: number
|
||||
postercount:
|
||||
type: number
|
||||
scheduled:
|
||||
type: number
|
||||
deleted:
|
||||
type: number
|
||||
deleterUid:
|
||||
|
||||
@@ -70,6 +70,8 @@ get:
|
||||
type: boolean
|
||||
topics:tag:
|
||||
type: boolean
|
||||
topics:schedule:
|
||||
type: boolean
|
||||
read:
|
||||
type: boolean
|
||||
posts:view_deleted:
|
||||
|
||||
@@ -335,6 +335,8 @@ get:
|
||||
type: boolean
|
||||
view_deleted:
|
||||
type: boolean
|
||||
view_scheduled:
|
||||
type: boolean
|
||||
isAdminOrMod:
|
||||
type: boolean
|
||||
disabled:
|
||||
|
||||
@@ -190,16 +190,20 @@ define('forum/category/tools', [
|
||||
var areAllDeleted = areAll(isTopicDeleted, tids);
|
||||
var isAnyPinned = isAny(isTopicPinned, tids);
|
||||
var isAnyLocked = isAny(isTopicLocked, tids);
|
||||
const isAnyScheduled = isAny(isTopicScheduled, tids);
|
||||
const areAllScheduled = areAll(isTopicScheduled, tids);
|
||||
|
||||
components.get('topic/delete').toggleClass('hidden', isAnyDeleted);
|
||||
components.get('topic/restore').toggleClass('hidden', !isAnyDeleted);
|
||||
components.get('topic/restore').toggleClass('hidden', isAnyScheduled || !isAnyDeleted);
|
||||
components.get('topic/purge').toggleClass('hidden', !areAllDeleted);
|
||||
|
||||
components.get('topic/lock').toggleClass('hidden', isAnyLocked);
|
||||
components.get('topic/unlock').toggleClass('hidden', !isAnyLocked);
|
||||
|
||||
components.get('topic/pin').toggleClass('hidden', isAnyPinned);
|
||||
components.get('topic/unpin').toggleClass('hidden', !isAnyPinned);
|
||||
components.get('topic/pin').toggleClass('hidden', areAllScheduled || isAnyPinned);
|
||||
components.get('topic/unpin').toggleClass('hidden', areAllScheduled || !isAnyPinned);
|
||||
|
||||
components.get('topic/merge').toggleClass('hidden', isAnyScheduled);
|
||||
}
|
||||
|
||||
function isAny(method, tids) {
|
||||
@@ -232,6 +236,10 @@ define('forum/category/tools', [
|
||||
return getTopicEl(tid).hasClass('pinned');
|
||||
}
|
||||
|
||||
function isTopicScheduled(tid) {
|
||||
return getTopicEl(tid).hasClass('scheduled');
|
||||
}
|
||||
|
||||
function getTopicEl(tid) {
|
||||
return components.get('category/topic', 'tid', tid);
|
||||
}
|
||||
|
||||
@@ -99,6 +99,9 @@ define('forum/topic/move-post', [
|
||||
if (!data || !data.tid) {
|
||||
return app.alertError('[[error:no-topic]]');
|
||||
}
|
||||
if (data.scheduled) {
|
||||
return app.alertError('[[error:cant-move-posts-to-scheduled]]');
|
||||
}
|
||||
var translateStr = translator.compile('topic:x-posts-will-be-moved-to-y', postSelect.pids.length, data.title);
|
||||
moveModal.find('#pids').translateHtml(translateStr);
|
||||
});
|
||||
|
||||
@@ -25,8 +25,8 @@ define('forum/topic/posts', [
|
||||
data.loggedIn = !!app.user.uid;
|
||||
data.privileges = ajaxify.data.privileges;
|
||||
|
||||
// prevent timeago in future by setting timestamp to 1 sec behind now
|
||||
data.posts[0].timestamp = Date.now() - 1000;
|
||||
// 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);
|
||||
|
||||
@@ -155,6 +155,10 @@
|
||||
style.push('unread');
|
||||
}
|
||||
|
||||
if (topic.scheduled) {
|
||||
style.push('scheduled');
|
||||
}
|
||||
|
||||
return style.join(' ');
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ define('topicList', [
|
||||
var loadTopicsCallback;
|
||||
var topicListEl;
|
||||
|
||||
const scheduledTopics = [];
|
||||
|
||||
$(window).on('action:ajaxify.start', function () {
|
||||
TopicList.removeListeners();
|
||||
categoryTools.removeListeners();
|
||||
@@ -95,54 +97,48 @@ define('topicList', [
|
||||
};
|
||||
|
||||
function onNewTopic(data) {
|
||||
if (
|
||||
(
|
||||
ajaxify.data.selectedCids &&
|
||||
ajaxify.data.selectedCids.length &&
|
||||
ajaxify.data.selectedCids.indexOf(parseInt(data.cid, 10)) === -1
|
||||
) ||
|
||||
(
|
||||
ajaxify.data.selectedFilter &&
|
||||
ajaxify.data.selectedFilter.filter === 'watched'
|
||||
) ||
|
||||
(
|
||||
ajaxify.data.template.category &&
|
||||
parseInt(ajaxify.data.cid, 10) !== parseInt(data.cid, 10)
|
||||
)
|
||||
) {
|
||||
const d = ajaxify.data;
|
||||
|
||||
const categories = d.selectedCids &&
|
||||
d.selectedCids.length &&
|
||||
d.selectedCids.indexOf(parseInt(data.cid, 10)) === -1;
|
||||
const filterWatched = d.selectedFilter &&
|
||||
d.selectedFilter.filter === 'watched';
|
||||
const category = d.template.category &&
|
||||
parseInt(d.cid, 10) !== parseInt(data.cid, 10);
|
||||
|
||||
if (categories || filterWatched || category || scheduledTopics.includes(data.tid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.scheduled && data.tid) {
|
||||
scheduledTopics.push(data.tid);
|
||||
}
|
||||
newTopicCount += 1;
|
||||
updateAlertText();
|
||||
}
|
||||
|
||||
function onNewPost(data) {
|
||||
var post = data.posts[0];
|
||||
if (!post || !post.topic) {
|
||||
if (!post || !post.topic || post.topic.isFollowing) {
|
||||
return;
|
||||
}
|
||||
if (!post.topic.isFollowing && (
|
||||
parseInt(post.topic.mainPid, 10) === parseInt(post.pid, 10) ||
|
||||
(
|
||||
ajaxify.data.selectedCids &&
|
||||
ajaxify.data.selectedCids.length &&
|
||||
ajaxify.data.selectedCids.indexOf(parseInt(post.topic.cid, 10)) === -1
|
||||
) ||
|
||||
(
|
||||
ajaxify.data.selectedFilter &&
|
||||
ajaxify.data.selectedFilter.filter === 'new'
|
||||
) ||
|
||||
(
|
||||
ajaxify.data.selectedFilter &&
|
||||
ajaxify.data.selectedFilter.filter === 'watched' &&
|
||||
!post.topic.isFollowing
|
||||
) ||
|
||||
(
|
||||
ajaxify.data.template.category &&
|
||||
parseInt(ajaxify.data.cid, 10) !== parseInt(post.topic.cid, 10)
|
||||
)
|
||||
)) {
|
||||
|
||||
const d = ajaxify.data;
|
||||
|
||||
const isMain = parseInt(post.topic.mainPid, 10) === parseInt(post.pid, 10);
|
||||
const categories = d.selectedCids &&
|
||||
d.selectedCids.length &&
|
||||
d.selectedCids.indexOf(parseInt(post.topic.cid, 10)) === -1;
|
||||
const filterNew = d.selectedFilter &&
|
||||
d.selectedFilter.filter === 'new';
|
||||
const filterWatched = d.selectedFilter &&
|
||||
d.selectedFilter.filter === 'watched' &&
|
||||
!post.topic.isFollowing;
|
||||
const category = d.template.category &&
|
||||
parseInt(d.cid, 10) !== parseInt(post.topic.cid, 10);
|
||||
|
||||
if (isMain || categories || filterNew || filterWatched || category) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ const events = require('../events');
|
||||
exports.setDefaultPostData = function (reqOrSocket, data) {
|
||||
data.uid = reqOrSocket.uid;
|
||||
data.req = exports.buildReqObject(reqOrSocket, { ...data });
|
||||
data.timestamp = Date.now();
|
||||
data.timestamp = parseInt(data.timestamp, 10) || Date.now();
|
||||
data.fromQueue = false;
|
||||
};
|
||||
|
||||
|
||||
@@ -20,7 +20,12 @@ topicsAPI.get = async function (caller, data) {
|
||||
privileges.topics.get(data.tid, caller.uid),
|
||||
topics.getTopicData(data.tid),
|
||||
]);
|
||||
if (!topic || !userPrivileges.read || !userPrivileges['topics:read'] || (topic.deleted && !userPrivileges.view_deleted)) {
|
||||
if (
|
||||
!topic ||
|
||||
!userPrivileges.read ||
|
||||
!userPrivileges['topics:read'] ||
|
||||
!privileges.topics.canViewDeletedScheduled(topic, userPrivileges)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ module.exports = function (Categories) {
|
||||
'groups:topics:delete',
|
||||
];
|
||||
const modPrivileges = defaultPrivileges.concat([
|
||||
'groups:topics:schedule',
|
||||
'groups:posts:view_deleted',
|
||||
'groups:purge',
|
||||
]);
|
||||
|
||||
@@ -55,7 +55,7 @@ module.exports = function (Categories) {
|
||||
topicData = await topics.getTopicData(postData.tid);
|
||||
}
|
||||
index += 1;
|
||||
} while (!topicData || topicData.deleted);
|
||||
} while (!topicData || topicData.deleted || topicData.scheduled);
|
||||
|
||||
if (postData && postData.tid) {
|
||||
await Categories.updateRecentTid(cid, postData.tid);
|
||||
|
||||
@@ -4,6 +4,7 @@ const db = require('../database');
|
||||
const topics = require('../topics');
|
||||
const plugins = require('../plugins');
|
||||
const meta = require('../meta');
|
||||
const privileges = require('../privileges');
|
||||
const user = require('../user');
|
||||
|
||||
module.exports = function (Categories) {
|
||||
@@ -142,7 +143,12 @@ module.exports = function (Categories) {
|
||||
});
|
||||
return result && result.pinnedTids;
|
||||
}
|
||||
const pinnedTids = await db.getSortedSetRevRange(`cid:${data.cid}:tids:pinned`, data.start, data.stop);
|
||||
const [allPinnedTids, canSchedule] = await Promise.all([
|
||||
db.getSortedSetRevRange(`cid:${data.cid}:tids:pinned`, data.start, data.stop),
|
||||
privileges.categories.can('topics:schedule', data.cid, data.uid),
|
||||
]);
|
||||
const pinnedTids = canSchedule ? allPinnedTids : await filterScheduledTids(allPinnedTids);
|
||||
|
||||
return await topics.tools.checkPinExpiry(pinnedTids);
|
||||
};
|
||||
|
||||
@@ -152,7 +158,7 @@ module.exports = function (Categories) {
|
||||
}
|
||||
|
||||
topics.forEach((topic) => {
|
||||
if (topic.deleted && !topic.isOwner) {
|
||||
if (!topic.scheduled && topic.deleted && !topic.isOwner) {
|
||||
topic.title = '[[topic:topic_is_deleted]]';
|
||||
topic.slug = topic.tid;
|
||||
topic.teaser = null;
|
||||
@@ -176,4 +182,10 @@ module.exports = function (Categories) {
|
||||
await Promise.all(promises);
|
||||
await Categories.updateRecentTidForCid(cid);
|
||||
};
|
||||
|
||||
async function filterScheduledTids(tids) {
|
||||
const scores = await db.sortedSetScores('topics:scheduled', tids);
|
||||
const now = Date.now();
|
||||
return tids.filter((tid, index) => tid && (!scores[index] || scores[index] <= now));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ const user = require('../../user');
|
||||
const posts = require('../../posts');
|
||||
const categories = require('../../categories');
|
||||
const meta = require('../../meta');
|
||||
const privileges = require('../../privileges');
|
||||
const accountHelpers = require('./helpers');
|
||||
const helpers = require('../helpers');
|
||||
const utils = require('../../utils');
|
||||
@@ -91,11 +92,13 @@ async function getPosts(callerUid, userData, setSuffix) {
|
||||
const count = 10;
|
||||
const postData = [];
|
||||
|
||||
const [isAdmin, isModOfCids] = await Promise.all([
|
||||
const [isAdmin, isModOfCids, canSchedule] = await Promise.all([
|
||||
user.isAdministrator(callerUid),
|
||||
user.isModerator(callerUid, cids),
|
||||
privileges.categories.isUserAllowedTo('topics:schedule', cids, callerUid),
|
||||
]);
|
||||
const cidToIsMod = _.zipObject(cids, isModOfCids);
|
||||
const cidToCanSchedule = _.zipObject(cids, canSchedule);
|
||||
|
||||
do {
|
||||
/* eslint-disable no-await-in-loop */
|
||||
@@ -106,7 +109,8 @@ async function getPosts(callerUid, userData, setSuffix) {
|
||||
if (pids.length) {
|
||||
const p = await posts.getPostSummaryByPids(pids, callerUid, { stripTags: false });
|
||||
postData.push(...p.filter(
|
||||
p => p && p.topic && (isAdmin || cidToIsMod[p.topic.cid] || (!p.deleted && !p.topic.deleted))
|
||||
p => p && p.topic && (isAdmin || cidToIsMod[p.topic.cid] ||
|
||||
(p.topic.scheduled && cidToCanSchedule[p.topic.cid]) || (!p.deleted && !p.topic.deleted))
|
||||
));
|
||||
}
|
||||
start += count;
|
||||
|
||||
@@ -43,15 +43,17 @@ topicsController.get = async function getTopic(req, res, callback) {
|
||||
|
||||
let currentPage = parseInt(req.query.page, 10) || 1;
|
||||
const pageCount = Math.max(1, Math.ceil((topicData && topicData.postcount) / settings.postsPerPage));
|
||||
const validPagination = (settings.usePagination && (currentPage < 1 || currentPage > pageCount));
|
||||
if (
|
||||
!topicData ||
|
||||
userPrivileges.disabled ||
|
||||
(settings.usePagination && (currentPage < 1 || currentPage > pageCount))
|
||||
validPagination ||
|
||||
(topicData.scheduled && !userPrivileges.view_scheduled)
|
||||
) {
|
||||
return callback();
|
||||
}
|
||||
|
||||
if (!userPrivileges['topics:read'] || (topicData.deleted && !userPrivileges.view_deleted)) {
|
||||
if (!userPrivileges['topics:read'] || (!topicData.scheduled && topicData.deleted && !userPrivileges.view_deleted)) {
|
||||
return helpers.notAllowed(req, res);
|
||||
}
|
||||
|
||||
@@ -343,7 +345,7 @@ topicsController.pagination = async function (req, res, callback) {
|
||||
return callback();
|
||||
}
|
||||
|
||||
if (!userPrivileges.read || (topic.deleted && !userPrivileges.view_deleted)) {
|
||||
if (!userPrivileges.read || !privileges.topics.canViewDeletedScheduled(topic, userPrivileges)) {
|
||||
return helpers.notAllowed(req, res);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,12 +38,12 @@ module.exports = function (Posts) {
|
||||
};
|
||||
|
||||
Diffs.save = async function (data) {
|
||||
const { pid, uid, oldContent, newContent } = data;
|
||||
const now = Date.now();
|
||||
const { pid, uid, oldContent, newContent, edited } = data;
|
||||
const editTimestamp = edited || Date.now();
|
||||
const patch = diff.createPatch('', newContent, oldContent);
|
||||
await Promise.all([
|
||||
db.listPrepend(`post:${pid}:diffs`, now),
|
||||
db.setObject(`diff:${pid}.${now}`, {
|
||||
db.listPrepend(`post:${pid}:diffs`, editTimestamp),
|
||||
db.setObject(`diff:${pid}.${editTimestamp}`, {
|
||||
uid: uid,
|
||||
pid: pid,
|
||||
patch: patch,
|
||||
@@ -135,7 +135,7 @@ module.exports = function (Posts) {
|
||||
function getValidatedTimestamp(timestamp) {
|
||||
timestamp = parseInt(timestamp, 10);
|
||||
|
||||
if (isNaN(timestamp) || timestamp > Date.now()) {
|
||||
if (isNaN(timestamp)) {
|
||||
throw new Error('[[error:invalid-data]]');
|
||||
}
|
||||
|
||||
|
||||
@@ -29,11 +29,13 @@ module.exports = function (Posts) {
|
||||
throw new Error('[[error:no-post]]');
|
||||
}
|
||||
|
||||
const topicData = await topics.getTopicFields(postData.tid, ['cid', 'title', 'timestamp', 'scheduled']);
|
||||
const oldContent = postData.content; // for diffing purposes
|
||||
const now = Date.now();
|
||||
// For posts in scheduled topics, if edited before, use edit timestamp
|
||||
const postTimestamp = topicData.scheduled ? (postData.edited || postData.timestamp) + 1 : Date.now();
|
||||
const editPostData = {
|
||||
content: data.content,
|
||||
edited: now,
|
||||
edited: postTimestamp,
|
||||
editor: data.uid,
|
||||
};
|
||||
if (data.handle) {
|
||||
@@ -49,7 +51,7 @@ module.exports = function (Posts) {
|
||||
|
||||
const [editor, topic] = await Promise.all([
|
||||
user.getUserFields(data.uid, ['username', 'userslug']),
|
||||
editMainPost(data, postData),
|
||||
editMainPost(data, postData, topicData),
|
||||
]);
|
||||
|
||||
await Posts.setPostFields(data.pid, result.post);
|
||||
@@ -60,6 +62,7 @@ module.exports = function (Posts) {
|
||||
uid: data.uid,
|
||||
oldContent: oldContent,
|
||||
newContent: data.content,
|
||||
edited: postTimestamp,
|
||||
});
|
||||
}
|
||||
await Posts.uploads.sync(data.pid);
|
||||
@@ -70,7 +73,7 @@ module.exports = function (Posts) {
|
||||
const returnPostData = { ...postData, ...result.post };
|
||||
returnPostData.cid = topic.cid;
|
||||
returnPostData.topic = topic;
|
||||
returnPostData.editedISO = utils.toISOString(now);
|
||||
returnPostData.editedISO = utils.toISOString(postTimestamp);
|
||||
returnPostData.changed = oldContent !== data.content;
|
||||
|
||||
await topics.notifyFollowers(returnPostData, data.uid, {
|
||||
@@ -93,15 +96,11 @@ module.exports = function (Posts) {
|
||||
};
|
||||
};
|
||||
|
||||
async function editMainPost(data, postData) {
|
||||
async function editMainPost(data, postData, topicData) {
|
||||
const { tid } = postData;
|
||||
const title = data.title ? data.title.trim() : '';
|
||||
|
||||
const [topicData, isMain] = await Promise.all([
|
||||
topics.getTopicFields(tid, ['cid', 'title', 'timestamp']),
|
||||
Posts.isMain(data.pid),
|
||||
]);
|
||||
|
||||
const isMain = await Posts.isMain(data.pid);
|
||||
if (!isMain) {
|
||||
return {
|
||||
tid: tid,
|
||||
|
||||
@@ -76,7 +76,7 @@ module.exports = function (Posts) {
|
||||
}
|
||||
|
||||
async function getTopicAndCategories(tids) {
|
||||
const topicsData = await topics.getTopicsFields(tids, ['uid', 'tid', 'title', 'cid', 'slug', 'deleted', 'postcount', 'mainPid', 'teaserPid']);
|
||||
const topicsData = await topics.getTopicsFields(tids, ['uid', 'tid', 'title', 'cid', 'slug', 'deleted', 'scheduled', 'postcount', 'mainPid', 'teaserPid']);
|
||||
const cids = _.uniq(topicsData.map(topic => topic && topic.cid));
|
||||
const categoriesData = await categories.getCategoriesFields(cids, ['cid', 'name', 'icon', 'slug', 'parentCid', 'bgColor', 'color', 'backgroundImage', 'imageClass']);
|
||||
return { topics: topicsData, categories: categoriesData };
|
||||
|
||||
@@ -18,6 +18,7 @@ privsCategories.privilegeLabels = [
|
||||
{ name: '[[admin/manage/privileges:access-topics]]' },
|
||||
{ name: '[[admin/manage/privileges:create-topics]]' },
|
||||
{ name: '[[admin/manage/privileges:reply-to-topics]]' },
|
||||
{ name: '[[admin/manage/privileges:schedule-topics]]' },
|
||||
{ name: '[[admin/manage/privileges:tag-topics]]' },
|
||||
{ name: '[[admin/manage/privileges:edit-posts]]' },
|
||||
{ name: '[[admin/manage/privileges:view-edit-history]]' },
|
||||
@@ -36,6 +37,7 @@ privsCategories.userPrivilegeList = [
|
||||
'topics:read',
|
||||
'topics:create',
|
||||
'topics:reply',
|
||||
'topics:schedule',
|
||||
'topics:tag',
|
||||
'posts:edit',
|
||||
'posts:history',
|
||||
@@ -79,8 +81,8 @@ privsCategories.list = async function (cid) {
|
||||
|
||||
privsCategories.get = async function (cid, uid) {
|
||||
const privs = [
|
||||
'topics:create', 'topics:read', 'topics:tag',
|
||||
'read', 'posts:view_deleted',
|
||||
'topics:create', 'topics:read', 'topics:schedule',
|
||||
'topics:tag', 'read', 'posts:view_deleted',
|
||||
];
|
||||
|
||||
const [userPrivileges, isAdministrator, isModerator] = await Promise.all([
|
||||
@@ -162,6 +164,7 @@ privsCategories.getBase = async function (privilege, cids, uid) {
|
||||
categories: categories.getCategoriesFields(cids, ['disabled']),
|
||||
allowedTo: helpers.isAllowedTo(privilege, uid, cids),
|
||||
view_deleted: helpers.isAllowedTo('posts:view_deleted', uid, cids),
|
||||
view_scheduled: helpers.isAllowedTo('topics:schedule', uid, cids),
|
||||
isAdmin: user.isAdministrator(uid),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ const helpers = require('./helpers');
|
||||
const plugins = require('../plugins');
|
||||
const utils = require('../utils');
|
||||
const privsCategories = require('./categories');
|
||||
const privsTopics = require('./topics');
|
||||
|
||||
const privsPosts = module.exports;
|
||||
|
||||
@@ -73,7 +74,7 @@ privsPosts.filter = async function (privilege, pids, uid) {
|
||||
pids = _.uniq(pids);
|
||||
const postData = await posts.getPostsFields(pids, ['uid', 'tid', 'deleted']);
|
||||
const tids = _.uniq(postData.map(post => post && post.tid).filter(Boolean));
|
||||
const topicData = await topics.getTopicsFields(tids, ['deleted', 'cid']);
|
||||
const topicData = await topics.getTopicsFields(tids, ['deleted', 'scheduled', 'cid']);
|
||||
|
||||
const tidToTopic = _.zipObject(tids, topicData);
|
||||
|
||||
@@ -93,11 +94,15 @@ privsPosts.filter = async function (privilege, pids, uid) {
|
||||
|
||||
const cidsSet = new Set(allowedCids);
|
||||
const canViewDeleted = _.zipObject(cids, results.view_deleted);
|
||||
const canViewScheduled = _.zipObject(cids, results.view_scheduled);
|
||||
|
||||
pids = postData.filter(post => (
|
||||
post.topic &&
|
||||
cidsSet.has(post.topic.cid) &&
|
||||
((!post.topic.deleted && !post.deleted) || canViewDeleted[post.topic.cid] || results.isAdmin)
|
||||
(privsTopics.canViewDeletedScheduled({
|
||||
deleted: post.topic.deleted || post.deleted,
|
||||
scheduled: post.topic.scheduled,
|
||||
}, {}, canViewDeleted[post.topic.cid], canViewScheduled[post.topic.cid]) || results.isAdmin)
|
||||
)).map(post => post.pid);
|
||||
|
||||
const data = await plugins.hooks.fire('filter:privileges.posts.filter', {
|
||||
|
||||
@@ -17,11 +17,11 @@ privsTopics.get = async function (tid, uid) {
|
||||
uid = parseInt(uid, 10);
|
||||
|
||||
const privs = [
|
||||
'topics:reply', 'topics:read', 'topics:tag',
|
||||
'topics:reply', 'topics:read', 'topics:schedule', 'topics:tag',
|
||||
'topics:delete', 'posts:edit', 'posts:history',
|
||||
'posts:delete', 'posts:view_deleted', 'read', 'purge',
|
||||
];
|
||||
const topicData = await topics.getTopicFields(tid, ['cid', 'uid', 'locked', 'deleted']);
|
||||
const topicData = await topics.getTopicFields(tid, ['cid', 'uid', 'locked', 'deleted', 'scheduled']);
|
||||
const [userPrivileges, isAdministrator, isModerator, disabled] = await Promise.all([
|
||||
helpers.isAllowedTo(privs, uid, topicData.cid),
|
||||
user.isAdministrator(uid),
|
||||
@@ -33,9 +33,10 @@ privsTopics.get = async function (tid, uid) {
|
||||
const isAdminOrMod = isAdministrator || isModerator;
|
||||
const editable = isAdminOrMod;
|
||||
const deletable = (privData['topics:delete'] && (isOwner || isModerator)) || isAdministrator;
|
||||
const mayReply = privsTopics.canViewDeletedScheduled(topicData, {}, false, privData['topics:schedule']);
|
||||
|
||||
return await plugins.hooks.fire('filter:privileges.topics.get', {
|
||||
'topics:reply': (privData['topics:reply'] && ((!topicData.locked && !topicData.deleted) || isModerator)) || isAdministrator,
|
||||
'topics:reply': (privData['topics:reply'] && ((!topicData.locked && mayReply) || isModerator)) || isAdministrator,
|
||||
'topics:read': privData['topics:read'] || isAdministrator,
|
||||
'topics:tag': privData['topics:tag'] || isAdministrator,
|
||||
'topics:delete': (privData['topics:delete'] && (isOwner || isModerator)) || isAdministrator,
|
||||
@@ -50,6 +51,7 @@ privsTopics.get = async function (tid, uid) {
|
||||
editable: editable,
|
||||
deletable: deletable,
|
||||
view_deleted: isAdminOrMod || isOwner || privData['posts:view_deleted'],
|
||||
view_scheduled: privData['topics:schedule'] || isAdministrator,
|
||||
isAdminOrMod: isAdminOrMod,
|
||||
disabled: disabled,
|
||||
tid: tid,
|
||||
@@ -67,7 +69,7 @@ privsTopics.filterTids = async function (privilege, tids, uid) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const topicsData = await topics.getTopicsFields(tids, ['tid', 'cid', 'deleted']);
|
||||
const topicsData = await topics.getTopicsFields(tids, ['tid', 'cid', 'deleted', 'scheduled']);
|
||||
const cids = _.uniq(topicsData.map(topic => topic.cid));
|
||||
const results = await privsCategories.getBase(privilege, cids, uid);
|
||||
|
||||
@@ -78,10 +80,11 @@ privsTopics.filterTids = async function (privilege, tids, uid) {
|
||||
|
||||
const cidsSet = new Set(allowedCids);
|
||||
const canViewDeleted = _.zipObject(cids, results.view_deleted);
|
||||
const canViewScheduled = _.zipObject(cids, results.view_scheduled);
|
||||
|
||||
tids = topicsData.filter(t => (
|
||||
cidsSet.has(t.cid) &&
|
||||
(!t.deleted || canViewDeleted[t.cid] || results.isAdmin)
|
||||
(results.isAdmin || privsTopics.canViewDeletedScheduled(t, {}, canViewDeleted[t.cid], canViewScheduled[t.cid]))
|
||||
)).map(t => t.tid);
|
||||
|
||||
const data = await plugins.hooks.fire('filter:privileges.topics.filter', {
|
||||
@@ -98,14 +101,20 @@ privsTopics.filterUids = async function (privilege, tid, uids) {
|
||||
}
|
||||
|
||||
uids = _.uniq(uids);
|
||||
const topicData = await topics.getTopicFields(tid, ['tid', 'cid', 'deleted']);
|
||||
const topicData = await topics.getTopicFields(tid, ['tid', 'cid', 'deleted', 'scheduled']);
|
||||
const [disabled, allowedTo, isAdmins] = await Promise.all([
|
||||
categories.getCategoryField(topicData.cid, 'disabled'),
|
||||
helpers.isUsersAllowedTo(privilege, uids, topicData.cid),
|
||||
user.isAdministrator(uids),
|
||||
]);
|
||||
|
||||
if (topicData.scheduled) {
|
||||
const canViewScheduled = await helpers.isUsersAllowedTo('topics:schedule', uids, topicData.cid);
|
||||
uids = uids.filter((uid, index) => canViewScheduled[index]);
|
||||
}
|
||||
|
||||
return uids.filter((uid, index) => !disabled &&
|
||||
((allowedTo[index] && !topicData.deleted) || isAdmins[index]));
|
||||
((allowedTo[index] && (topicData.scheduled || !topicData.deleted)) || isAdmins[index]));
|
||||
};
|
||||
|
||||
privsTopics.canPurge = async function (tid, uid) {
|
||||
@@ -163,3 +172,20 @@ privsTopics.isAdminOrMod = async function (tid, uid) {
|
||||
const cid = await topics.getTopicField(tid, 'cid');
|
||||
return await privsCategories.isAdminOrMod(cid, uid);
|
||||
};
|
||||
|
||||
privsTopics.canViewDeletedScheduled = function (topic, privileges = {}, viewDeleted = false, viewScheduled = false) {
|
||||
if (!topic) {
|
||||
return false;
|
||||
}
|
||||
const { deleted = false, scheduled = false } = topic;
|
||||
const { view_deleted = viewDeleted, view_scheduled = viewScheduled } = privileges;
|
||||
|
||||
// conceptually exclusive, scheduled topics deemed to be not deleted (they can only be purged)
|
||||
if (scheduled) {
|
||||
return view_scheduled;
|
||||
} else if (deleted) {
|
||||
return view_deleted;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -72,7 +72,7 @@ async function generateForTopic(req, res) {
|
||||
topics.getTopicData(tid),
|
||||
]);
|
||||
|
||||
if (!topic || (topic.deleted && !userPrivileges.view_deleted)) {
|
||||
if (!privileges.topics.canViewDeletedScheduled(topic, userPrivileges)) {
|
||||
return controllers404.send404(req, res);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,10 +14,10 @@ module.exports = function (SocketTopics) {
|
||||
|
||||
const [userPrivileges, topicData] = await Promise.all([
|
||||
privileges.topics.get(data.tid, socket.uid),
|
||||
topics.getTopicFields(data.tid, ['postcount', 'deleted', 'uid']),
|
||||
topics.getTopicFields(data.tid, ['postcount', 'deleted', 'scheduled', 'uid']),
|
||||
]);
|
||||
|
||||
if (!userPrivileges['topics:read'] || (topicData.deleted && !userPrivileges.view_deleted)) {
|
||||
if (!userPrivileges['topics:read'] || !privileges.topics.canViewDeletedScheduled(topicData, userPrivileges)) {
|
||||
throw new Error('[[error:no-privileges]]');
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ start.start = async function () {
|
||||
require('./notifications').startJobs();
|
||||
require('./user').startJobs();
|
||||
require('./plugins').startJobs();
|
||||
require('./topics').scheduled.startJobs();
|
||||
await db.delete('locks');
|
||||
}
|
||||
|
||||
|
||||
@@ -38,23 +38,33 @@ module.exports = function (Topics) {
|
||||
topicData = result.topic;
|
||||
await db.setObject(`topic:${topicData.tid}`, topicData);
|
||||
|
||||
await Promise.all([
|
||||
db.sortedSetsAdd([
|
||||
const timestampedSortedSetKeys = [
|
||||
'topics:tid',
|
||||
`cid:${topicData.cid}:tids`,
|
||||
`cid:${topicData.cid}:uid:${topicData.uid}:tids`,
|
||||
], timestamp, topicData.tid),
|
||||
];
|
||||
|
||||
const scheduled = timestamp > Date.now();
|
||||
if (scheduled) {
|
||||
timestampedSortedSetKeys.push('topics:scheduled');
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
db.sortedSetsAdd(timestampedSortedSetKeys, timestamp, topicData.tid),
|
||||
db.sortedSetsAdd([
|
||||
'topics:views', 'topics:posts', 'topics:votes',
|
||||
`cid:${topicData.cid}:tids:votes`,
|
||||
`cid:${topicData.cid}:tids:posts`,
|
||||
], 0, topicData.tid),
|
||||
categories.updateRecentTid(topicData.cid, topicData.tid),
|
||||
user.addTopicIdToUser(topicData.uid, topicData.tid, timestamp),
|
||||
db.incrObjectField(`category:${topicData.cid}`, 'topic_count'),
|
||||
db.incrObjectField('global', 'topicCount'),
|
||||
Topics.createTags(data.tags, topicData.tid, timestamp),
|
||||
scheduled ? Promise.resolve() : categories.updateRecentTid(topicData.cid, topicData.tid),
|
||||
]);
|
||||
if (scheduled) {
|
||||
await Topics.scheduled.pin(tid, topicData);
|
||||
}
|
||||
|
||||
plugins.hooks.fire('action:topic.save', { topic: _.clone(topicData), data: data });
|
||||
return topicData.tid;
|
||||
@@ -118,10 +128,14 @@ module.exports = function (Topics) {
|
||||
topicData.index = 0;
|
||||
postData.index = 0;
|
||||
|
||||
if (topicData.scheduled) {
|
||||
await Topics.delete(tid);
|
||||
}
|
||||
|
||||
analytics.increment(['topics', `topics:byCid:${topicData.cid}`]);
|
||||
plugins.hooks.fire('action:topic.post', { topic: topicData, post: postData, data: data });
|
||||
|
||||
if (parseInt(uid, 10)) {
|
||||
if (parseInt(uid, 10) && !topicData.scheduled) {
|
||||
user.notifications.sendTopicNotificationToFollowers(uid, topicData, postData);
|
||||
}
|
||||
|
||||
@@ -136,29 +150,11 @@ module.exports = function (Topics) {
|
||||
const { uid } = data;
|
||||
|
||||
const topicData = await Topics.getTopicData(tid);
|
||||
if (!topicData) {
|
||||
throw new Error('[[error:no-topic]]');
|
||||
}
|
||||
|
||||
await canReply(data, topicData);
|
||||
|
||||
data.cid = topicData.cid;
|
||||
|
||||
const [canReply, isAdminOrMod] = await Promise.all([
|
||||
privileges.topics.can('topics:reply', tid, uid),
|
||||
privileges.categories.isAdminOrMod(data.cid, uid),
|
||||
]);
|
||||
|
||||
if (topicData.locked && !isAdminOrMod) {
|
||||
throw new Error('[[error:topic-locked]]');
|
||||
}
|
||||
|
||||
if (topicData.deleted && !isAdminOrMod) {
|
||||
throw new Error('[[error:topic-deleted]]');
|
||||
}
|
||||
|
||||
if (!canReply) {
|
||||
throw new Error('[[error:no-privileges]]');
|
||||
}
|
||||
|
||||
await guestHandleValid(data);
|
||||
if (!data.fromQueue) {
|
||||
await user.isReadyToPost(uid, data.cid);
|
||||
@@ -169,6 +165,11 @@ module.exports = function (Topics) {
|
||||
}
|
||||
Topics.checkContent(data.content);
|
||||
|
||||
// For replies to scheduled topics, don't have a timestamp older than topic's itself
|
||||
if (topicData.scheduled) {
|
||||
data.timestamp = topicData.lastposttime + 1;
|
||||
}
|
||||
|
||||
data.ip = data.req ? data.req.ip : null;
|
||||
let postData = await posts.create(data);
|
||||
postData = await onNewPost(postData, data);
|
||||
@@ -207,7 +208,7 @@ module.exports = function (Topics) {
|
||||
topicInfo,
|
||||
] = await Promise.all([
|
||||
posts.getUserInfoForPosts([postData.uid], uid),
|
||||
Topics.getTopicFields(tid, ['tid', 'uid', 'title', 'slug', 'cid', 'postcount', 'mainPid']),
|
||||
Topics.getTopicFields(tid, ['tid', 'uid', 'title', 'slug', 'cid', 'postcount', 'mainPid', 'scheduled']),
|
||||
Topics.addParentPosts([postData]),
|
||||
posts.parsePost(postData),
|
||||
]);
|
||||
@@ -263,4 +264,34 @@ module.exports = function (Topics) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function canReply(data, topicData) {
|
||||
if (!topicData) {
|
||||
throw new Error('[[error:no-topic]]');
|
||||
}
|
||||
const { tid, uid } = data;
|
||||
const { cid, deleted, locked, scheduled } = topicData;
|
||||
|
||||
const [canReply, canSchedule, isAdminOrMod] = await Promise.all([
|
||||
privileges.topics.can('topics:reply', tid, uid),
|
||||
privileges.topics.can('topics:schedule', tid, uid),
|
||||
privileges.categories.isAdminOrMod(cid, uid),
|
||||
]);
|
||||
|
||||
if (locked && !isAdminOrMod) {
|
||||
throw new Error('[[error:topic-locked]]');
|
||||
}
|
||||
|
||||
if (!scheduled && deleted && !isAdminOrMod) {
|
||||
throw new Error('[[error:topic-deleted]]');
|
||||
}
|
||||
|
||||
if (scheduled && !canSchedule) {
|
||||
throw new Error('[[error:no-privileges]]');
|
||||
}
|
||||
|
||||
if (!canReply) {
|
||||
throw new Error('[[error:no-privileges]]');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -20,6 +20,12 @@ module.exports = function (Topics) {
|
||||
if (!Array.isArray(tids) || !tids.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// "scheduled" is derived from "timestamp"
|
||||
if (fields.includes('scheduled') && !fields.includes('timestamp')) {
|
||||
fields.push('timestamp');
|
||||
}
|
||||
|
||||
const keys = tids.map(tid => `topic:${tid}`);
|
||||
const topics = await db.getObjects(keys, fields);
|
||||
const result = await plugins.hooks.fire('filter:topic.getFields', {
|
||||
@@ -100,6 +106,9 @@ function modifyTopic(topic, fields) {
|
||||
|
||||
if (topic.hasOwnProperty('timestamp')) {
|
||||
topic.timestampISO = utils.toISOString(topic.timestamp);
|
||||
if (!fields.length || fields.includes('scheduled')) {
|
||||
topic.scheduled = topic.timestamp > Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
if (topic.hasOwnProperty('lastposttime')) {
|
||||
|
||||
@@ -95,6 +95,7 @@ module.exports = function (Topics) {
|
||||
'topics:posts',
|
||||
'topics:views',
|
||||
'topics:votes',
|
||||
'topics:scheduled',
|
||||
], tid),
|
||||
deleteTopicFromCategoryAndUser(tid),
|
||||
Topics.deleteTopicTags(tid),
|
||||
|
||||
@@ -39,7 +39,14 @@ module.exports = function (Topics) {
|
||||
if (!isAdminOrMod) {
|
||||
throw new Error('[[error:no-privileges]]');
|
||||
}
|
||||
const tid = await Topics.create({ uid: postData.uid, title: title, cid: cid });
|
||||
|
||||
const scheduled = postData.timestamp > Date.now();
|
||||
const tid = await Topics.create({
|
||||
uid: postData.uid,
|
||||
title: title,
|
||||
cid: cid,
|
||||
timestamp: scheduled && postData.timestamp,
|
||||
});
|
||||
await Topics.updateTopicBookmarks(fromTid, pids);
|
||||
|
||||
await async.eachSeries(pids, async (pid) => {
|
||||
@@ -47,10 +54,10 @@ module.exports = function (Topics) {
|
||||
if (!canEdit.flag) {
|
||||
throw new Error(canEdit.message);
|
||||
}
|
||||
await Topics.movePostToTopic(uid, pid, tid);
|
||||
await Topics.movePostToTopic(uid, pid, tid, scheduled);
|
||||
});
|
||||
|
||||
await Topics.updateLastPostTime(tid, Date.now());
|
||||
await Topics.updateLastPostTime(tid, scheduled ? (postData.timestamp + 1) : Date.now());
|
||||
|
||||
await Promise.all([
|
||||
Topics.setTopicFields(tid, {
|
||||
@@ -65,17 +72,25 @@ module.exports = function (Topics) {
|
||||
return await Topics.getTopicData(tid);
|
||||
};
|
||||
|
||||
Topics.movePostToTopic = async function (callerUid, pid, tid) {
|
||||
Topics.movePostToTopic = async function (callerUid, pid, tid, forceScheduled = false) {
|
||||
tid = parseInt(tid, 10);
|
||||
const exists = await Topics.exists(tid);
|
||||
if (!exists) {
|
||||
const topicData = await Topics.getTopicFields(tid, ['tid', 'scheduled']);
|
||||
if (!topicData.tid) {
|
||||
throw new Error('[[error:no-topic]]');
|
||||
}
|
||||
if (!forceScheduled && topicData.scheduled) {
|
||||
throw new Error('[[error:cant-move-posts-to-scheduled]]');
|
||||
}
|
||||
const postData = await posts.getPostFields(pid, ['tid', 'uid', 'timestamp', 'upvotes', 'downvotes']);
|
||||
if (!postData || !postData.tid) {
|
||||
throw new Error('[[error:no-post]]');
|
||||
}
|
||||
|
||||
const isSourceTopicScheduled = await Topics.getTopicField(postData.tid, 'scheduled');
|
||||
if (!forceScheduled && isSourceTopicScheduled) {
|
||||
throw new Error('[[error:cant-move-from-scheduled-to-existing]]');
|
||||
}
|
||||
|
||||
if (postData.tid === tid) {
|
||||
throw new Error('[[error:cant-move-to-same-topic]]');
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ require('./posts')(Topics);
|
||||
require('./follow')(Topics);
|
||||
require('./tags')(Topics);
|
||||
require('./teaser')(Topics);
|
||||
Topics.scheduled = require('./scheduled');
|
||||
require('./suggested')(Topics);
|
||||
require('./tools')(Topics);
|
||||
Topics.thumbs = require('./thumbs');
|
||||
|
||||
@@ -6,6 +6,12 @@ const plugins = require('../plugins');
|
||||
module.exports = function (Topics) {
|
||||
Topics.merge = async function (tids, uid, options) {
|
||||
options = options || {};
|
||||
|
||||
const topicsData = await Topics.getTopicsFields(tids, ['scheduled']);
|
||||
if (topicsData.some(t => t.scheduled)) {
|
||||
throw new Error('[[error:cant-merge-scheduled]]');
|
||||
}
|
||||
|
||||
const oldestTid = findOldestTopic(tids);
|
||||
let mergeIntoTid = oldestTid;
|
||||
if (options.mainTid) {
|
||||
|
||||
104
src/topics/scheduled.js
Normal file
104
src/topics/scheduled.js
Normal file
@@ -0,0 +1,104 @@
|
||||
'use strict';
|
||||
|
||||
const _ = require('lodash');
|
||||
const winston = require('winston');
|
||||
const { CronJob } = require('cron');
|
||||
|
||||
const db = require('../database');
|
||||
const posts = require('../posts');
|
||||
const socketHelpers = require('../socket.io/helpers');
|
||||
const topics = require('./index');
|
||||
const user = require('../user');
|
||||
|
||||
const Scheduled = module.exports;
|
||||
|
||||
Scheduled.startJobs = function () {
|
||||
winston.verbose('[scheduled topics] Starting jobs.');
|
||||
new CronJob('*/1 * * * *', Scheduled.handleExpired, null, true);
|
||||
};
|
||||
|
||||
Scheduled.handleExpired = async function () {
|
||||
const now = Date.now();
|
||||
const tids = await db.getSortedSetRangeByScore('topics:scheduled', 0, -1, '-inf', now);
|
||||
|
||||
if (!tids.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
let topicsData = await topics.getTopicsData(tids);
|
||||
// Filter deleted
|
||||
topicsData = topicsData.filter(topicData => Boolean(topicData));
|
||||
const uids = _.uniq(topicsData.map(topicData => topicData.uid)).filter(uid => uid); // Filter guests topics
|
||||
|
||||
// Restore first to be not filtered for being deleted
|
||||
// Restoring handles "updateRecentTid"
|
||||
await Promise.all(topicsData.map(topicData => topics.restore(topicData.tid)));
|
||||
|
||||
await Promise.all([].concat(
|
||||
sendNotifications(uids, topicsData),
|
||||
updateUserLastposttimes(uids, topicsData),
|
||||
...topicsData.map(topicData => unpin(topicData.tid, topicData)),
|
||||
db.sortedSetsRemoveRangeByScore([`topics:scheduled`], '-inf', now)
|
||||
));
|
||||
};
|
||||
|
||||
// topics/tools.js#pin/unpin would block non-admins/mods, thus the local versions
|
||||
Scheduled.pin = async function (tid, topicData) {
|
||||
return Promise.all([
|
||||
topics.setTopicField(tid, 'pinned', 1),
|
||||
db.sortedSetAdd(`cid:${topicData.cid}:tids:pinned`, Date.now(), tid),
|
||||
db.sortedSetsRemove([
|
||||
`cid:${topicData.cid}:tids`,
|
||||
`cid:${topicData.cid}:tids:posts`,
|
||||
`cid:${topicData.cid}:tids:votes`,
|
||||
], tid),
|
||||
]);
|
||||
};
|
||||
|
||||
function unpin(tid, topicData) {
|
||||
return [
|
||||
topics.setTopicField(tid, 'pinned', 0),
|
||||
topics.deleteTopicField(tid, 'pinExpiry'),
|
||||
db.sortedSetRemove(`cid:${topicData.cid}:tids:pinned`, tid),
|
||||
db.sortedSetAddBulk([
|
||||
[`cid:${topicData.cid}:tids`, topicData.lastposttime, tid],
|
||||
[`cid:${topicData.cid}:tids:posts`, topicData.postcount, tid],
|
||||
[`cid:${topicData.cid}:tids:votes`, parseInt(topicData.votes, 10) || 0, tid],
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
async function sendNotifications(uids, topicsData) {
|
||||
const usernames = await Promise.all(uids.map(uid => user.getUserField(uid, 'username')));
|
||||
const uidToUsername = Object.fromEntries(uids.map((uid, idx) => [uid, usernames[idx]]));
|
||||
|
||||
const postsData = await posts.getPostsData(topicsData.map(({ mainPid }) => mainPid));
|
||||
postsData.forEach((postData, idx) => {
|
||||
postData.user = {};
|
||||
postData.user.username = uidToUsername[postData.uid];
|
||||
postData.topic = topicsData[idx];
|
||||
});
|
||||
|
||||
return topicsData.map(
|
||||
(t, idx) => user.notifications.sendTopicNotificationToFollowers(t.uid, t, postsData[idx])
|
||||
).concat(
|
||||
topicsData.map(
|
||||
(t, idx) => socketHelpers.notifyNew(t.uid, 'newTopic', { posts: [postsData[idx]], topic: t })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function updateUserLastposttimes(uids, topicsData) {
|
||||
const lastposttimes = (await user.getUsersFields(uids, ['lastposttime'])).map(u => u.lastposttime);
|
||||
|
||||
let timestampByUid = {};
|
||||
topicsData.forEach((tD) => {
|
||||
timestampByUid[tD.uid] = timestampByUid[tD.uid] ? timestampByUid[tD.uid].concat(tD.timestamp) : [tD.timestamp];
|
||||
});
|
||||
timestampByUid = Object.fromEntries(
|
||||
Object.entries(timestampByUid).filter(uidTimestamp => [uidTimestamp[0], Math.max(...uidTimestamp[1])])
|
||||
);
|
||||
|
||||
const uidsToUpdate = uids.filter((uid, idx) => timestampByUid[uid] > lastposttimes[idx]);
|
||||
return uidsToUpdate.map(uid => user.setUserField(uid, 'lastposttime', String(timestampByUid[uid])));
|
||||
}
|
||||
@@ -27,6 +27,10 @@ module.exports = function (Topics) {
|
||||
if (!topicData) {
|
||||
throw new Error('[[error:no-topic]]');
|
||||
}
|
||||
// Scheduled topics can only be purged
|
||||
if (topicData.scheduled) {
|
||||
throw new Error('[[error:invalid-data]]');
|
||||
}
|
||||
const canDelete = await privileges.topics.canDelete(tid, uid);
|
||||
|
||||
const data = await plugins.hooks.fire(isDelete ? 'filter:topic.delete' : 'filter:topic.restore', { topicData: topicData, uid: uid, isDelete: isDelete, canDelete: canDelete, canRestore: canDelete });
|
||||
@@ -149,6 +153,10 @@ module.exports = function (Topics) {
|
||||
throw new Error('[[error:no-topic]]');
|
||||
}
|
||||
|
||||
if (topicData.scheduled) {
|
||||
throw new Error('[[error:cant-pin-scheduled]]');
|
||||
}
|
||||
|
||||
if (uid !== 'system' && !await privileges.topics.isAdminOrMod(tid, uid)) {
|
||||
throw new Error('[[error:no-privileges]]');
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ module.exports = function (Topics) {
|
||||
db.getSortedSetRevRangeWithScores(`uid:${params.uid}:tids_unread`, 0, -1),
|
||||
]);
|
||||
|
||||
const userReadTime = _.mapValues(_.keyBy(userScores, 'value'), 'score');
|
||||
const userReadTimes = _.mapValues(_.keyBy(userScores, 'value'), 'score');
|
||||
const isTopicsFollowed = {};
|
||||
followedTids.forEach((t) => {
|
||||
isTopicsFollowed[t.value] = true;
|
||||
@@ -115,7 +115,7 @@ module.exports = function (Topics) {
|
||||
});
|
||||
|
||||
const unreadTopics = _.unionWith(categoryTids, followedTids, (a, b) => a.value === b.value)
|
||||
.filter(t => !ignoredTids.includes(t.value) && (!userReadTime[t.value] || t.score > userReadTime[t.value]))
|
||||
.filter(t => !ignoredTids.includes(t.value) && (!userReadTimes[t.value] || t.score > userReadTimes[t.value]))
|
||||
.concat(tids_unread.filter(t => !ignoredTids.includes(t.value)))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
@@ -135,7 +135,8 @@ module.exports = function (Topics) {
|
||||
});
|
||||
|
||||
tids = await privileges.topics.filterTids('topics:read', tids, params.uid);
|
||||
const topicData = (await Topics.getTopicsFields(tids, ['tid', 'cid', 'uid', 'postcount', 'deleted'])).filter(t => !t.deleted);
|
||||
const topicData = (await Topics.getTopicsFields(tids, ['tid', 'cid', 'uid', 'postcount', 'deleted', 'scheduled']))
|
||||
.filter(t => t.scheduled || !t.deleted);
|
||||
const topicCids = _.uniq(topicData.map(topic => topic.cid)).filter(Boolean);
|
||||
|
||||
const categoryWatchState = await categories.getWatchState(topicCids, params.uid);
|
||||
@@ -157,7 +158,7 @@ module.exports = function (Topics) {
|
||||
tidsByFilter.unreplied.push(topic.tid);
|
||||
}
|
||||
|
||||
if (!userReadTime[topic.tid]) {
|
||||
if (!userReadTimes[topic.tid]) {
|
||||
tidsByFilter.new.push(topic.tid);
|
||||
}
|
||||
}
|
||||
@@ -273,19 +274,19 @@ module.exports = function (Topics) {
|
||||
return false;
|
||||
}
|
||||
const [topicScores, userScores] = await Promise.all([
|
||||
Topics.getTopicsFields(tids, ['tid', 'lastposttime']),
|
||||
Topics.getTopicsFields(tids, ['tid', 'lastposttime', 'scheduled']),
|
||||
db.sortedSetScores(`uid:${uid}:tids_read`, tids),
|
||||
]);
|
||||
|
||||
tids = topicScores.filter((t, i) => t.lastposttime && (!userScores[i] || userScores[i] < t.lastposttime))
|
||||
.map(t => t.tid);
|
||||
const topics = topicScores.filter((t, i) => t.lastposttime && (!userScores[i] || userScores[i] < t.lastposttime));
|
||||
tids = topics.map(t => t.tid);
|
||||
|
||||
if (!tids.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const scores = tids.map(() => now);
|
||||
const scores = topics.map(topic => (topic.scheduled ? topic.lastposttime : now));
|
||||
const [topicData] = await Promise.all([
|
||||
Topics.getTopicsFields(tids, ['cid']),
|
||||
db.sortedSetAdd(`uid:${uid}:tids_read`, scores, tids),
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
'use strict';
|
||||
|
||||
const db = require('../../database');
|
||||
const privileges = require('../../privileges');
|
||||
|
||||
module.exports = {
|
||||
name: 'Add "schedule" to default privileges of admins and gmods for existing categories',
|
||||
timestamp: Date.UTC(2021, 2, 11),
|
||||
method: async () => {
|
||||
const privilegeToGive = ['groups:topics:schedule'];
|
||||
|
||||
const cids = await db.getSortedSetRevRange('categories:cid', 0, -1);
|
||||
for (const cid of cids) {
|
||||
/* eslint-disable no-await-in-loop */
|
||||
await privileges.categories.give(privilegeToGive, cid, ['administrators', 'Global Moderators']);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -53,9 +53,12 @@ module.exports = function (User) {
|
||||
}
|
||||
|
||||
User.onNewPostMade = async function (postData) {
|
||||
// For scheduled posts, use "action" time. It'll be updated in related cron job when post is published
|
||||
const lastposttime = postData.timestamp > Date.now() ? Date.now() : postData.timestamp;
|
||||
|
||||
await User.addPostIdToUser(postData);
|
||||
await User.incrementUserPostCountBy(postData.uid, 1);
|
||||
await User.setUserField(postData.uid, 'lastposttime', postData.timestamp);
|
||||
await User.setUserField(postData.uid, 'lastposttime', lastposttime);
|
||||
await User.updateLastOnlineTime(postData.uid);
|
||||
};
|
||||
|
||||
|
||||
@@ -764,6 +764,7 @@ describe('Categories', () => {
|
||||
'topics:create': false,
|
||||
'topics:tag': false,
|
||||
'topics:delete': false,
|
||||
'topics:schedule': false,
|
||||
'posts:edit': false,
|
||||
'posts:history': false,
|
||||
'posts:upvote': false,
|
||||
@@ -815,6 +816,7 @@ describe('Categories', () => {
|
||||
'groups:topics:create': true,
|
||||
'groups:topics:reply': true,
|
||||
'groups:topics:tag': true,
|
||||
'groups:topics:schedule': false,
|
||||
'groups:posts:delete': true,
|
||||
'groups:read': true,
|
||||
'groups:topics:read': true,
|
||||
|
||||
173
test/topics.js
173
test/topics.js
@@ -29,6 +29,7 @@ describe('Topic\'s', () => {
|
||||
let categoryObj;
|
||||
let adminUid;
|
||||
let adminJar;
|
||||
let csrf_token;
|
||||
let fooUid;
|
||||
|
||||
before(async () => {
|
||||
@@ -36,6 +37,7 @@ describe('Topic\'s', () => {
|
||||
fooUid = await User.create({ username: 'foo' });
|
||||
await groups.join('administrators', adminUid);
|
||||
adminJar = await helpers.loginUser('admin', '123456');
|
||||
csrf_token = (await requestType('get', `${nconf.get('url')}/api/config`, { json: true, jar: adminJar })).body.csrf_token;
|
||||
|
||||
categoryObj = await categories.create({
|
||||
name: 'Test Category',
|
||||
@@ -2639,4 +2641,175 @@ describe('Topic\'s', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('scheduled topics', () => {
|
||||
let categoryObj;
|
||||
let topicData;
|
||||
let topic;
|
||||
let adminApiOpts;
|
||||
let postData;
|
||||
const replyData = {
|
||||
form: {
|
||||
content: 'a reply by guest',
|
||||
},
|
||||
json: true,
|
||||
};
|
||||
|
||||
before(async () => {
|
||||
adminApiOpts = {
|
||||
json: true,
|
||||
jar: adminJar,
|
||||
headers: {
|
||||
'x-csrf-token': csrf_token,
|
||||
},
|
||||
};
|
||||
categoryObj = await categories.create({
|
||||
name: 'Another Test Category',
|
||||
description: 'Another test category created by testing script',
|
||||
});
|
||||
topic = {
|
||||
uid: adminUid,
|
||||
cid: categoryObj.cid,
|
||||
title: 'Scheduled Test Topic Title',
|
||||
content: 'The content of scheduled test topic',
|
||||
timestamp: new Date(Date.now() + 86400000).getTime(),
|
||||
};
|
||||
});
|
||||
|
||||
it('should create a scheduled topic as pinned, deleted, included in "topics:scheduled" zset and with a timestamp in future', async () => {
|
||||
topicData = (await topics.post(topic)).topicData;
|
||||
topicData = await topics.getTopicData(topicData.tid);
|
||||
|
||||
assert(topicData.pinned);
|
||||
assert(topicData.deleted);
|
||||
assert(topicData.scheduled);
|
||||
assert(topicData.timestamp > Date.now());
|
||||
const score = await db.sortedSetScore('topics:scheduled', topicData.tid);
|
||||
assert(score);
|
||||
// should not be in regular category zsets
|
||||
const isMember = await db.isMemberOfSortedSets([
|
||||
`cid:${categoryObj.cid}:tids`,
|
||||
`cid:${categoryObj.cid}:tids:votes`,
|
||||
`cid:${categoryObj.cid}:tids:posts`,
|
||||
], topicData.tid);
|
||||
assert.deepStrictEqual(isMember, [false, false, false]);
|
||||
});
|
||||
|
||||
it('should not update poster\'s lastposttime', async () => {
|
||||
const data = await User.getUsersFields([adminUid], ['lastposttime']);
|
||||
assert.notStrictEqual(data[0].lastposttime, topicData.lastposttime);
|
||||
});
|
||||
|
||||
it('should not load topic for an unprivileged user', async () => {
|
||||
const response = await requestType('get', `${nconf.get('url')}/topic/${topicData.slug}`);
|
||||
assert.strictEqual(response.statusCode, 404);
|
||||
assert(response.body);
|
||||
});
|
||||
|
||||
it('should load topic for a privileged user', async () => {
|
||||
const response = (await requestType('get', `${nconf.get('url')}/topic/${topicData.slug}`, { jar: adminJar })).res;
|
||||
assert.strictEqual(response.statusCode, 200);
|
||||
assert(response.body);
|
||||
});
|
||||
|
||||
it('should not be amongst topics of the category for an unprivileged user', async () => {
|
||||
const response = await requestType('get', `${nconf.get('url')}/api/category/${categoryObj.slug}`, { json: true });
|
||||
assert.strictEqual(response.body.topics.filter(topic => topic.tid === topicData.tid).length, 0);
|
||||
});
|
||||
|
||||
it('should be amongst topics of the category for a privileged user', async () => {
|
||||
const response = await requestType('get', `${nconf.get('url')}/api/category/${categoryObj.slug}`, { json: true, jar: adminJar });
|
||||
const topic = response.body.topics.filter(topic => topic.tid === topicData.tid)[0];
|
||||
assert.strictEqual(topic && topic.tid, topicData.tid);
|
||||
});
|
||||
|
||||
it('should load topic for guests if privilege is given', async () => {
|
||||
await privileges.categories.give(['groups:topics:schedule'], categoryObj.cid, 'guests');
|
||||
const response = await requestType('get', `${nconf.get('url')}/topic/${topicData.slug}`);
|
||||
assert.strictEqual(response.statusCode, 200);
|
||||
assert(response.body);
|
||||
});
|
||||
|
||||
it('should be amongst topics of the category for guests if privilege is given', async () => {
|
||||
const response = await requestType('get', `${nconf.get('url')}/api/category/${categoryObj.slug}`, { json: true });
|
||||
const topic = response.body.topics.filter(topic => topic.tid === topicData.tid)[0];
|
||||
assert.strictEqual(topic && topic.tid, topicData.tid);
|
||||
});
|
||||
|
||||
it('should not allow deletion of a scheduled topic', async () => {
|
||||
const response = await requestType('delete', `${nconf.get('url')}/api/v3/topics/${topicData.tid}/state`, adminApiOpts);
|
||||
assert.strictEqual(response.res.statusCode, 400);
|
||||
});
|
||||
|
||||
it('should not allow to unpin a scheduled topic', async () => {
|
||||
const response = await requestType('delete', `${nconf.get('url')}/api/v3/topics/${topicData.tid}/pin`, adminApiOpts);
|
||||
assert.strictEqual(response.res.statusCode, 400);
|
||||
});
|
||||
|
||||
it('should not allow to restore a scheduled topic', async () => {
|
||||
const response = await requestType('put', `${nconf.get('url')}/api/v3/topics/${topicData.tid}/state`, adminApiOpts);
|
||||
assert.strictEqual(response.res.statusCode, 400);
|
||||
});
|
||||
|
||||
it('should not allow unprivileged to reply', async () => {
|
||||
await privileges.categories.rescind(['groups:topics:schedule'], categoryObj.cid, 'guests');
|
||||
await privileges.categories.give(['groups:topics:reply'], categoryObj.cid, 'guests');
|
||||
const response = await requestType('post', `${nconf.get('url')}/api/v3/topics/${topicData.tid}`, replyData);
|
||||
assert.strictEqual(response.res.statusCode, 403);
|
||||
});
|
||||
|
||||
it('should allow guests to reply if privilege is given', async () => {
|
||||
await privileges.categories.give(['groups:topics:schedule'], categoryObj.cid, 'guests');
|
||||
const response = await requestType('post', `${nconf.get('url')}/api/v3/topics/${topicData.tid}`, replyData);
|
||||
assert.strictEqual(response.body.response.content, 'a reply by guest');
|
||||
assert.strictEqual(response.body.response.user.username, '[[global:guest]]');
|
||||
});
|
||||
|
||||
it('should have replies with greater timestamp than the scheduled topics itself', async () => {
|
||||
const response = await requestType('get', `${nconf.get('url')}/api/topic/${topicData.slug}`, { json: true });
|
||||
postData = response.body.posts[1];
|
||||
assert(postData.timestamp > response.body.posts[0].timestamp);
|
||||
});
|
||||
|
||||
it('should have post edits with greater timestamp than the original', async () => {
|
||||
const editData = { ...adminApiOpts, form: { content: 'an edit by the admin' } };
|
||||
const result = await requestType('put', `${nconf.get('url')}/api/v3/posts/${postData.pid}`, editData);
|
||||
assert(result.body.response.edited > postData.timestamp);
|
||||
|
||||
const diffsResult = await requestType('get', `${nconf.get('url')}/api/v3/posts/${postData.pid}/diffs`, adminApiOpts);
|
||||
const { revisions } = diffsResult.body.response;
|
||||
// diffs are LIFO
|
||||
assert(revisions[0].timestamp > revisions[1].timestamp);
|
||||
});
|
||||
|
||||
it('should allow to purge a scheduled topic', async () => {
|
||||
const response = await requestType('delete', `${nconf.get('url')}/api/v3/topics/${topicData.tid}`, adminApiOpts);
|
||||
assert.strictEqual(response.res.statusCode, 200);
|
||||
});
|
||||
|
||||
it('should remove from topics:scheduled on purge', async () => {
|
||||
const score = await db.sortedSetScore('topics:scheduled', topicData.tid);
|
||||
assert(!score);
|
||||
});
|
||||
|
||||
it('should able to publish a scheduled topic', async () => {
|
||||
topicData = (await topics.post(topic)).topicData;
|
||||
// Manually trigger publishing
|
||||
await db.sortedSetRemove('topics:scheduled', topicData.tid);
|
||||
await db.sortedSetAdd('topics:scheduled', Date.now() - 1000, topicData.tid);
|
||||
await topics.scheduled.handleExpired();
|
||||
|
||||
topicData = await topics.getTopicData(topicData.tid);
|
||||
assert(!topicData.pinned);
|
||||
assert(!topicData.deleted);
|
||||
// Should remove from topics:scheduled upon publishing
|
||||
const score = await db.sortedSetScore('topics:scheduled', topicData.tid);
|
||||
assert(!score);
|
||||
});
|
||||
|
||||
it('should update poster\'s lastposttime after a ST published', async () => {
|
||||
const data = await User.getUsersFields([adminUid], ['lastposttime']);
|
||||
assert.strictEqual(data[0].lastposttime, topicData.lastposttime);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user