feat: scheduled topics (#9399)

* feat: scheduled topics

* refactor: linting fixes

* fix: tests

* fix(test): race condition

* fix: make a single request
v1.18.x
gasoved 4 years ago committed by GitHub
parent 67b09cba5f
commit 077330b764
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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);
const timestampedSortedSetKeys = [
'topics:tid',
`cid:${topicData.cid}:tids`,
`cid:${topicData.cid}:uid:${topicData.uid}:tids`,
];
const scheduled = timestamp > Date.now();
if (scheduled) {
timestampedSortedSetKeys.push('topics:scheduled');
}
await Promise.all([
db.sortedSetsAdd([
'topics:tid',
`cid:${topicData.cid}:tids`,
`cid:${topicData.cid}:uid:${topicData.uid}:tids`,
], timestamp, topicData.tid),
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,28 +150,10 @@ module.exports = function (Topics) {
const { uid } = data;
const topicData = await Topics.getTopicData(tid);
if (!topicData) {
throw new Error('[[error:no-topic]]');
}
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]]');
}
await canReply(data, topicData);
if (!canReply) {
throw new Error('[[error:no-privileges]]');
}
data.cid = topicData.cid;
await guestHandleValid(data);
if (!data.fromQueue) {
@ -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) {

@ -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,

@ -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);
});
});
});

Loading…
Cancel
Save