mirror of
https://github.com/NodeBB/NodeBB.git
synced 2026-01-05 23:30:36 +01:00
Post queue write api (#13473)
* move post queue from socket.io to rest api * move harmony post-queue to core add canEdit, allow users to edit their queued posts * fix: openapi spec * lint: whitespace
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
get:
|
||||
tags:
|
||||
- admin
|
||||
summary: Get flag data
|
||||
summary: Get post queue
|
||||
responses:
|
||||
"200":
|
||||
description: ""
|
||||
@@ -42,6 +42,8 @@ get:
|
||||
description: A user identifier
|
||||
type:
|
||||
type: string
|
||||
canEdit:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -202,6 +202,10 @@ paths:
|
||||
$ref: 'write/posts/pid/diffs/timestamp.yaml'
|
||||
/posts/{pid}/replies:
|
||||
$ref: 'write/posts/pid/replies.yaml'
|
||||
/posts/queue/{id}:
|
||||
$ref: 'write/posts/queue/id.yaml'
|
||||
/posts/queue/{id}/notify:
|
||||
$ref: 'write/posts/queue/notify.yaml'
|
||||
/chats/:
|
||||
$ref: 'write/chats.yaml'
|
||||
/chats/unread:
|
||||
|
||||
92
public/openapi/write/posts/queue/id.yaml
Normal file
92
public/openapi/write/posts/queue/id.yaml
Normal file
@@ -0,0 +1,92 @@
|
||||
post:
|
||||
summary: Accept a queued post
|
||||
tags:
|
||||
- QueuedPosts
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
description: a valid queued post id
|
||||
example: 2
|
||||
responses:
|
||||
'200':
|
||||
description: post successfully accepted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
$ref: ../../../components/schemas/Status.yaml#/Status
|
||||
response:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
pid:
|
||||
type: number
|
||||
tid:
|
||||
type: number
|
||||
'400':
|
||||
description: Bad request, invalid post id
|
||||
delete:
|
||||
summary: Remove a queued post
|
||||
tags:
|
||||
- QueuedPosts
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: 'topic-12345'
|
||||
responses:
|
||||
'200':
|
||||
description: Post removed successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
$ref: ../../../components/schemas/Status.yaml#/Status
|
||||
response:
|
||||
type: object
|
||||
'400':
|
||||
description: Bad request, invalid post id
|
||||
put:
|
||||
summary: Edit a queued post
|
||||
tags:
|
||||
- QueuedPosts
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: 'topic-12345'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
content:
|
||||
type: string
|
||||
example: This is a test reply
|
||||
cid:
|
||||
type: number
|
||||
description: Category ID to which the post belongs
|
||||
title:
|
||||
type: string
|
||||
description: Updated Post Title
|
||||
responses:
|
||||
'200':
|
||||
description: Post edited successfully
|
||||
'400':
|
||||
description: Bad request, invalid post id
|
||||
|
||||
|
||||
36
public/openapi/write/posts/queue/notify.yaml
Normal file
36
public/openapi/write/posts/queue/notify.yaml
Normal file
@@ -0,0 +1,36 @@
|
||||
post:
|
||||
summary: Notify the owner of a queued post
|
||||
tags:
|
||||
- QueuedPosts
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
description: a valid queued post id
|
||||
example: 2
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
example: body of the notification message
|
||||
responses:
|
||||
'200':
|
||||
description: post successfully accepted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
$ref: ../../../components/schemas/Status.yaml#/Status
|
||||
response:
|
||||
type: object
|
||||
'400':
|
||||
description: Bad request, invalid post id
|
||||
@@ -69,14 +69,10 @@ define('forum/post-queue', [
|
||||
const id = textarea.parents('[data-id]').attr('data-id');
|
||||
const titleEdit = triggerClass === '[data-action="editTitle"]';
|
||||
|
||||
socket.emit('posts.editQueuedContent', {
|
||||
id: id,
|
||||
api.put(`/posts/queue/${id}`, {
|
||||
title: titleEdit ? textarea.val() : undefined,
|
||||
content: titleEdit ? undefined : textarea.val(),
|
||||
}, function (err, data) {
|
||||
if (err) {
|
||||
return alerts.error(err);
|
||||
}
|
||||
}).then((data) => {
|
||||
if (titleEdit) {
|
||||
preview.find('.title-text').text(data.postData.title);
|
||||
} else {
|
||||
@@ -85,7 +81,7 @@ define('forum/post-queue', [
|
||||
|
||||
textarea.parent().addClass('hidden');
|
||||
preview.removeClass('hidden');
|
||||
});
|
||||
}).catch(alerts.error);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -96,8 +92,7 @@ define('forum/post-queue', [
|
||||
onSubmit: function (selectedCategory) {
|
||||
Promise.all([
|
||||
api.get(`/categories/${selectedCategory.cid}`, {}),
|
||||
socket.emit('posts.editQueuedContent', {
|
||||
id: id,
|
||||
api.put(`/posts/queue/${id}`, {
|
||||
cid: selectedCategory.cid,
|
||||
}),
|
||||
]).then(function (result) {
|
||||
@@ -174,6 +169,35 @@ define('forum/post-queue', [
|
||||
|
||||
async function handleQueueActions() {
|
||||
// accept, reject, notify
|
||||
|
||||
const parent = $(this).parents('[data-id]');
|
||||
const action = $(this).attr('data-action');
|
||||
const id = parent.attr('data-id');
|
||||
const listContainer = parent.get(0).parentNode;
|
||||
|
||||
if ((!['accept', 'reject', 'notify'].includes(action)) ||
|
||||
(action === 'reject' && !await confirmReject(ajaxify.data.canAccept ? '[[post-queue:confirm-reject]]' : '[[post-queue:confirm-remove]]'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
doAction(action, id).then(function () {
|
||||
if (action === 'accept' || action === 'reject') {
|
||||
parent.remove();
|
||||
}
|
||||
|
||||
if (listContainer.childElementCount === 0) {
|
||||
if (ajaxify.data.singlePost) {
|
||||
ajaxify.go('/post-queue' + window.location.search);
|
||||
} else {
|
||||
ajaxify.refresh();
|
||||
}
|
||||
}
|
||||
}).catch(alerts.error);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function doAction(action, id) {
|
||||
function getMessage() {
|
||||
return new Promise((resolve) => {
|
||||
const modal = bootbox.dialog({
|
||||
@@ -194,36 +218,16 @@ define('forum/post-queue', [
|
||||
});
|
||||
}
|
||||
|
||||
const parent = $(this).parents('[data-id]');
|
||||
const action = $(this).attr('data-action');
|
||||
const id = parent.attr('data-id');
|
||||
const listContainer = parent.get(0).parentNode;
|
||||
|
||||
if ((!['accept', 'reject', 'notify'].includes(action)) ||
|
||||
(action === 'reject' && !await confirmReject(ajaxify.data.canAccept ? '[[post-queue:confirm-reject]]' : '[[post-queue:confirm-remove]]'))) {
|
||||
return;
|
||||
const actionsMap = {
|
||||
accept: () => api.post(`/posts/queue/${id}`, {}),
|
||||
reject: () => api.del(`/posts/queue/${id}`, {}),
|
||||
notify: async () => api.post(`/posts/queue/${id}/notify`, { message: await getMessage() }),
|
||||
};
|
||||
if (actionsMap[action]) {
|
||||
const result = actionsMap[action]();
|
||||
return (result instanceof Promise ? result : Promise.resolve(result));
|
||||
}
|
||||
|
||||
socket.emit('posts.' + action, {
|
||||
id: id,
|
||||
message: action === 'notify' ? await getMessage() : undefined,
|
||||
}, function (err) {
|
||||
if (err) {
|
||||
return alerts.error(err);
|
||||
}
|
||||
if (action === 'accept' || action === 'reject') {
|
||||
parent.remove();
|
||||
}
|
||||
|
||||
if (listContainer.childElementCount === 0) {
|
||||
if (ajaxify.data.singlePost) {
|
||||
ajaxify.go('/post-queue' + window.location.search);
|
||||
} else {
|
||||
ajaxify.refresh();
|
||||
}
|
||||
}
|
||||
});
|
||||
return false;
|
||||
throw new Error(`Unknown action: ${action}`);
|
||||
}
|
||||
|
||||
function handleBulkActions() {
|
||||
@@ -244,7 +248,7 @@ define('forum/post-queue', [
|
||||
return;
|
||||
}
|
||||
const action = bulkAction.split('-')[0];
|
||||
const promises = ids.map(id => socket.emit('posts.' + action, { id: id }));
|
||||
const promises = ids.map(id => doAction(action, id));
|
||||
|
||||
Promise.allSettled(promises).then(function (results) {
|
||||
const fulfilled = results.filter(res => res.status === 'fulfilled').length;
|
||||
|
||||
@@ -17,6 +17,8 @@ const activitypub = require('../activitypub');
|
||||
const apiHelpers = require('./helpers');
|
||||
const websockets = require('../socket.io');
|
||||
const socketHelpers = require('../socket.io/helpers');
|
||||
const translator = require('../translator');
|
||||
const notifications = require('../notifications');
|
||||
|
||||
const postsAPI = module.exports;
|
||||
|
||||
@@ -574,3 +576,91 @@ postsAPI.getReplies = async (caller, { pid }) => {
|
||||
|
||||
return postData;
|
||||
};
|
||||
|
||||
postsAPI.acceptQueuedPost = async (caller, data) => {
|
||||
await canEditQueue(caller.uid, data, 'accept');
|
||||
const result = await posts.submitFromQueue(data.id);
|
||||
if (result && caller.uid !== parseInt(result.uid, 10)) {
|
||||
await sendQueueNotification('post-queue-accepted', result.uid, `/post/${result.pid}`);
|
||||
}
|
||||
await logQueueEvent(caller, result, 'accept');
|
||||
return { type: result.type, pid: result.pid, tid: result.tid };
|
||||
};
|
||||
|
||||
postsAPI.removeQueuedPost = async (caller, data) => {
|
||||
await canEditQueue(caller.uid, data, 'reject');
|
||||
const result = await posts.removeFromQueue(data.id);
|
||||
if (result && caller.uid !== parseInt(result.uid, 10)) {
|
||||
await sendQueueNotification('post-queue-rejected', result.uid, '/');
|
||||
}
|
||||
await logQueueEvent(caller, result, 'reject');
|
||||
};
|
||||
|
||||
postsAPI.editQueuedPost = async (caller, data) => {
|
||||
if (!data || !data.id || (!data.content && !data.title && !data.cid)) {
|
||||
throw new Error('[[error:invalid-data]]');
|
||||
}
|
||||
await posts.editQueuedContent(caller.uid, data);
|
||||
if (data.content) {
|
||||
return await plugins.hooks.fire('filter:parse.post', { postData: data });
|
||||
}
|
||||
return { postData: data };
|
||||
};
|
||||
|
||||
postsAPI.notifyQueuedPostOwner = async (caller, data) => {
|
||||
await canEditQueue(caller.uid, data, 'notify');
|
||||
const result = await posts.getFromQueue(data.id);
|
||||
if (result) {
|
||||
await sendQueueNotification('post-queue-notify', result.uid, `/post-queue/${data.id}`, validator.escape(String(data.message)));
|
||||
}
|
||||
};
|
||||
|
||||
async function canEditQueue(uid, data, action) {
|
||||
const [canEditQueue, queuedPost] = await Promise.all([
|
||||
posts.canEditQueue(uid, data, action),
|
||||
posts.getFromQueue(data.id),
|
||||
]);
|
||||
if (!queuedPost) {
|
||||
throw new Error('[[error:no-post]]');
|
||||
}
|
||||
if (!canEditQueue) {
|
||||
throw new Error('[[error:no-privileges]]');
|
||||
}
|
||||
}
|
||||
|
||||
async function logQueueEvent(caller, result, type) {
|
||||
const eventData = {
|
||||
type: `post-queue-${result.type}-${type}`,
|
||||
uid: caller.uid,
|
||||
ip: caller.ip,
|
||||
content: result.data.content,
|
||||
targetUid: result.uid,
|
||||
};
|
||||
if (result.type === 'topic') {
|
||||
eventData.cid = result.data.cid;
|
||||
eventData.title = result.data.title;
|
||||
} else {
|
||||
eventData.tid = result.data.tid;
|
||||
}
|
||||
if (result.pid) {
|
||||
eventData.pid = result.pid;
|
||||
}
|
||||
await events.log(eventData);
|
||||
}
|
||||
|
||||
async function sendQueueNotification(type, targetUid, path, notificationText) {
|
||||
const bodyShort = notificationText ?
|
||||
translator.compile(`notifications:${type}`, notificationText) :
|
||||
translator.compile(`notifications:${type}`);
|
||||
const notifData = {
|
||||
type: type,
|
||||
nid: `${type}-${targetUid}-${path}`,
|
||||
bodyShort: bodyShort,
|
||||
path: path,
|
||||
};
|
||||
if (parseInt(meta.config.postQueueNotificationUid, 10) > 0) {
|
||||
notifData.from = meta.config.postQueueNotificationUid;
|
||||
}
|
||||
const notifObj = await notifications.create(notifData);
|
||||
await notifications.push(notifObj, [targetUid]);
|
||||
}
|
||||
|
||||
@@ -237,6 +237,7 @@ modsController.postQueue = async function (req, res, next) {
|
||||
.map((post) => {
|
||||
const isSelf = post.user.uid === req.uid;
|
||||
post.canAccept = !isSelf && (isAdmin || isGlobalMod || !!moderatedCids.length);
|
||||
post.canEdit = isSelf || isAdmin || isGlobalMod;
|
||||
return post;
|
||||
});
|
||||
|
||||
|
||||
@@ -189,3 +189,24 @@ Posts.getReplies = async (req, res) => {
|
||||
|
||||
helpers.formatApiResponse(200, res, { replies });
|
||||
};
|
||||
|
||||
Posts.acceptQueuedPost = async (req, res) => {
|
||||
const post = await api.posts.acceptQueuedPost(req, { id: req.params.id });
|
||||
helpers.formatApiResponse(200, res, { post });
|
||||
};
|
||||
|
||||
Posts.removeQueuedPost = async (req, res) => {
|
||||
await api.posts.removeQueuedPost(req, { id: req.params.id });
|
||||
helpers.formatApiResponse(200, res);
|
||||
};
|
||||
|
||||
Posts.editQueuedPost = async (req, res) => {
|
||||
const result = await api.posts.editQueuedPost(req, { id: req.params.id, ...req.body });
|
||||
helpers.formatApiResponse(200, res, result);
|
||||
};
|
||||
|
||||
Posts.notifyQueuedPostOwner = async (req, res) => {
|
||||
const { id } = req.params;
|
||||
await api.posts.notifyQueuedPostOwner(req, { id, message: req.body.message });
|
||||
helpers.formatApiResponse(200, res);
|
||||
};
|
||||
@@ -307,9 +307,11 @@ module.exports = function (Posts) {
|
||||
if (data.type === 'topic') {
|
||||
const result = await createTopic(data.data);
|
||||
data.pid = result.postData.pid;
|
||||
data.tid = result.topicData.tid;
|
||||
} else if (data.type === 'reply') {
|
||||
const result = await createReply(data.data);
|
||||
data.pid = result.pid;
|
||||
data.tid = result.tid;
|
||||
}
|
||||
await removeFromQueue(id);
|
||||
plugins.hooks.fire('action:post-queue:submitFromQueue', { data: data });
|
||||
|
||||
@@ -41,6 +41,12 @@ module.exports = function () {
|
||||
|
||||
setupApiRoute(router, 'get', '/:pid/replies', [middleware.assert.post], controllers.write.posts.getReplies);
|
||||
|
||||
setupApiRoute(router, 'post', '/queue/:id', controllers.write.posts.acceptQueuedPost);
|
||||
setupApiRoute(router, 'delete', '/queue/:id', controllers.write.posts.removeQueuedPost);
|
||||
setupApiRoute(router, 'put', '/queue/:id', controllers.write.posts.editQueuedPost);
|
||||
setupApiRoute(router, 'post', '/queue/:id/notify', [middleware.checkRequired.bind(null, ['message'])], controllers.write.posts.notifyQueuedPostOwner);
|
||||
|
||||
|
||||
// Shorthand route to access post routes by topic index
|
||||
router.all('/+byIndex/:index*?', [middleware.checkRequired.bind(null, ['tid'])], controllers.write.posts.redirectByIndex);
|
||||
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
'use strict';
|
||||
|
||||
const validator = require('validator');
|
||||
|
||||
const db = require('../database');
|
||||
const posts = require('../posts');
|
||||
const privileges = require('../privileges');
|
||||
const plugins = require('../plugins');
|
||||
const meta = require('../meta');
|
||||
const topics = require('../topics');
|
||||
const notifications = require('../notifications');
|
||||
const utils = require('../utils');
|
||||
const events = require('../events');
|
||||
const translator = require('../translator');
|
||||
|
||||
const api = require('../api');
|
||||
const sockets = require('.');
|
||||
|
||||
@@ -99,90 +91,23 @@ SocketPosts.getReplies = async function (socket, pid) {
|
||||
};
|
||||
|
||||
SocketPosts.accept = async function (socket, data) {
|
||||
await canEditQueue(socket, data, 'accept');
|
||||
const result = await posts.submitFromQueue(data.id);
|
||||
if (result && socket.uid !== parseInt(result.uid, 10)) {
|
||||
await sendQueueNotification('post-queue-accepted', result.uid, `/post/${result.pid}`);
|
||||
}
|
||||
await logQueueEvent(socket, result, 'accept');
|
||||
sockets.warnDeprecated(socket, 'POST /api/v3/posts/queue/:id');
|
||||
await api.posts.acceptQueuedPost(socket, data);
|
||||
};
|
||||
|
||||
SocketPosts.reject = async function (socket, data) {
|
||||
await canEditQueue(socket, data, 'reject');
|
||||
const result = await posts.removeFromQueue(data.id);
|
||||
if (result && socket.uid !== parseInt(result.uid, 10)) {
|
||||
await sendQueueNotification('post-queue-rejected', result.uid, '/');
|
||||
}
|
||||
await logQueueEvent(socket, result, 'reject');
|
||||
sockets.warnDeprecated(socket, 'DELETE /api/v3/posts/queue/:id');
|
||||
await api.posts.removeQueuedPost(socket, data);
|
||||
};
|
||||
|
||||
async function logQueueEvent(socket, result, type) {
|
||||
const eventData = {
|
||||
type: `post-queue-${result.type}-${type}`,
|
||||
uid: socket.uid,
|
||||
ip: socket.ip,
|
||||
content: result.data.content,
|
||||
targetUid: result.uid,
|
||||
};
|
||||
if (result.type === 'topic') {
|
||||
eventData.cid = result.data.cid;
|
||||
eventData.title = result.data.title;
|
||||
} else {
|
||||
eventData.tid = result.data.tid;
|
||||
}
|
||||
if (result.pid) {
|
||||
eventData.pid = result.pid;
|
||||
}
|
||||
await events.log(eventData);
|
||||
}
|
||||
|
||||
SocketPosts.notify = async function (socket, data) {
|
||||
await canEditQueue(socket, data, 'notify');
|
||||
const result = await posts.getFromQueue(data.id);
|
||||
if (result) {
|
||||
await sendQueueNotification('post-queue-notify', result.uid, `/post-queue/${data.id}`, validator.escape(String(data.message)));
|
||||
}
|
||||
sockets.warnDeprecated(socket, 'POST /api/v3/posts/queue/:id/notify');
|
||||
await api.posts.notifyQueuedPostOwner(socket, data);
|
||||
};
|
||||
|
||||
async function canEditQueue(socket, data, action) {
|
||||
const [canEditQueue, queuedPost] = await Promise.all([
|
||||
posts.canEditQueue(socket.uid, data, action),
|
||||
posts.getFromQueue(data.id),
|
||||
]);
|
||||
if (!queuedPost) {
|
||||
throw new Error('[[error:no-post]]');
|
||||
}
|
||||
if (!canEditQueue) {
|
||||
throw new Error('[[error:no-privileges]]');
|
||||
}
|
||||
}
|
||||
|
||||
async function sendQueueNotification(type, targetUid, path, notificationText) {
|
||||
const bodyShort = notificationText ?
|
||||
translator.compile(`notifications:${type}`, notificationText) :
|
||||
translator.compile(`notifications:${type}`);
|
||||
const notifData = {
|
||||
type: type,
|
||||
nid: `${type}-${targetUid}-${path}`,
|
||||
bodyShort: bodyShort,
|
||||
path: path,
|
||||
};
|
||||
if (parseInt(meta.config.postQueueNotificationUid, 10) > 0) {
|
||||
notifData.from = meta.config.postQueueNotificationUid;
|
||||
}
|
||||
const notifObj = await notifications.create(notifData);
|
||||
await notifications.push(notifObj, [targetUid]);
|
||||
}
|
||||
|
||||
SocketPosts.editQueuedContent = async function (socket, data) {
|
||||
if (!data || !data.id || (!data.content && !data.title && !data.cid)) {
|
||||
throw new Error('[[error:invalid-data]]');
|
||||
}
|
||||
await posts.editQueuedContent(socket.uid, data);
|
||||
if (data.content) {
|
||||
return await plugins.hooks.fire('filter:parse.post', { postData: data });
|
||||
}
|
||||
return { postData: data };
|
||||
sockets.warnDeprecated(socket, 'PUT /api/v3/posts/queue/:id');
|
||||
return await api.posts.editQueuedPost(socket, data);
|
||||
};
|
||||
|
||||
require('../promisify')(SocketPosts);
|
||||
|
||||
@@ -1,92 +1,158 @@
|
||||
{{{ if isAdmin }}}
|
||||
{{{ if !enabled }}}
|
||||
<div class="alert alert-info">
|
||||
[[post-queue:enabling-help, {config.relative_path}/admin/settings/post#post-queue]]
|
||||
</div>
|
||||
{{{ end }}}
|
||||
{{{ else }}}
|
||||
<div>
|
||||
<p class="lead">[[post-queue:public-intro]]</p>
|
||||
<p>[[post-queue:public-description]]</p>
|
||||
<hr />
|
||||
</div>
|
||||
{{{ end }}}
|
||||
|
||||
{{{ if (!singlePost && posts.length) }}}
|
||||
<div class="btn-toolbar justify-content-end">
|
||||
<div class="me-2">
|
||||
<!-- IMPORT partials/category/filter-dropdown-right.tpl -->
|
||||
<div class="flex-fill">
|
||||
{{{ if isAdmin }}}
|
||||
{{{ if !enabled }}}
|
||||
<div class="alert alert-info">
|
||||
[[post-queue:enabling-help, {config.relative_path}/admin/settings/post#post-queue]]
|
||||
</div>
|
||||
<div class="btn-group bottom-sheet" component="post-queue/bulk-actions">
|
||||
<button type="button" class="btn btn-ghost btn-sm dropdown-toggle d-flex gap-2 align-items-center" data-bs-toggle="dropdown" autocomplete="off" aria-haspopup="true" aria-expanded="false">
|
||||
<i class="fa fa-clone text-primary"></i>
|
||||
<span class="fw-semibold">[[post-queue:bulk-actions]]</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end p-1" role="menu">
|
||||
{{{ if canAccept }}}
|
||||
<li><a class="dropdown-item rounded-1" href="#" data-action="accept-all" role="menuitem">[[post-queue:accept-all]]</a></li>
|
||||
<li><a class="dropdown-item rounded-1" href="#" data-action="accept-selected" role="menuitem">[[post-queue:accept-selected]]</a></li>
|
||||
<li class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item rounded-1" href="#" data-action="reject-all" role="menuitem">[[post-queue:reject-all]]</a></li>
|
||||
<li><a class="dropdown-item rounded-1" href="#" data-action="reject-selected" role="menuitem">[[post-queue:reject-selected]]</a></li>
|
||||
{{{ else }}}
|
||||
<li><a class="dropdown-item rounded-1" href="#" data-action="reject-all">[[post-queue:remove-all]]</a></li>
|
||||
<li><a class="dropdown-item rounded-1" href="#" data-action="reject-selected" role="menuitem">[[post-queue:remove-selected]]</a></li>
|
||||
{{{ end }}}
|
||||
</ul>
|
||||
{{{ end }}}
|
||||
{{{ else }}}
|
||||
<div>
|
||||
<p class="lead">[[post-queue:public-intro]]</p>
|
||||
<p>[[post-queue:public-description]]</p>
|
||||
<hr />
|
||||
</div>
|
||||
</div>
|
||||
{{{ end }}}
|
||||
|
||||
<hr/>
|
||||
{{{ end }}}
|
||||
{{{ if (!singlePost && posts.length) }}}
|
||||
<div class="btn-toolbar justify-content-end mb-3">
|
||||
<div class="me-2">
|
||||
<!-- IMPORT partials/category/filter-dropdown-right.tpl -->
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="post-queue preventSlideout posts-list">
|
||||
{{{ if !posts.length }}}
|
||||
{{{ if !singlePost }}}
|
||||
<div class="mx-auto">
|
||||
<div class="d-flex flex-column gap-3 justify-content-center text-center">
|
||||
<div class="mx-auto p-4 bg-light border rounded">
|
||||
<i class="text-secondary fa fa-fw fa-4x fa-seedling"></i>
|
||||
</div>
|
||||
[[post-queue:no-queued-posts]]
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group bottom-sheet" component="post-queue/bulk-actions">
|
||||
<button type="button" class="btn btn-ghost btn-sm ff-secondary dropdown-toggle d-flex align-items-center gap-2" data-bs-toggle="dropdown" autocomplete="off" aria-haspopup="true" aria-expanded="false">
|
||||
<i class="fa fa-clone text-primary"></i><span class="fw-semibold"> [[post-queue:bulk-actions]]</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu p-1 text-sm dropdown-menu-end" role="menu">
|
||||
{{{ if canAccept }}}
|
||||
<li><a class="dropdown-item rounded-1" href="#" data-action="accept-all" role="menuitem">[[post-queue:accept-all]]</a></li>
|
||||
<li><a class="dropdown-item rounded-1" href="#" data-action="accept-selected" role="menuitem">[[post-queue:accept-selected]]</a></li>
|
||||
<li class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item rounded-1" href="#" data-action="reject-all" role="menuitem">[[post-queue:reject-all]]</a></li>
|
||||
<li><a class="dropdown-item rounded-1" href="#" data-action="reject-selected" role="menuitem">[[post-queue:reject-selected]]</a></li>
|
||||
{{{ else }}}
|
||||
<div class="alert alert-info d-flex align-items-md-center d-flex flex-column flex-md-row">
|
||||
<p class="mb-md-0">[[post-queue:no-single-post]]</p>
|
||||
<div class="d-grid ms-md-auto">
|
||||
<a class="btn btn-sm btn-primary flex-shrink text-nowrap" href=".">[[post-queue:back-to-list]]</a>
|
||||
</div>
|
||||
</div>
|
||||
<li><a class="dropdown-item rounded-1" href="#" data-action="reject-all" role="menuitem">[[post-queue:remove-all]]</a></li>
|
||||
<li><a class="dropdown-item rounded-1" href="#" data-action="reject-selected" role="menuitem">[[post-queue:remove-selected]]</a></li>
|
||||
{{{ end }}}
|
||||
{{{ end }}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{{ end }}}
|
||||
|
||||
{{{ each posts }}}
|
||||
<div class="card mb-3" data-id="{./id}"data-uid="{./user.uid}">
|
||||
<div class="card-header">
|
||||
{{{ if !singlePost }}}
|
||||
<input type="checkbox" class="form-check-input" autocomplete="off" />
|
||||
{{{ end }}}
|
||||
<strong>{{{ if posts.data.tid }}}[[post-queue:reply]]{{{ else }}}[[post-queue:topic]]{{{ end }}}</strong>
|
||||
<span class="timeago float-end" title={posts.data.timestampISO}></span>
|
||||
<div class="post-queue posts-list">
|
||||
{{{ if !posts.length }}}
|
||||
{{{ if !singlePost }}}
|
||||
<div class="mx-auto">
|
||||
<div class="d-flex flex-column gap-3 justify-content-center text-center">
|
||||
<div class="mx-auto p-4 bg-light border rounded">
|
||||
<i class="text-secondary fa fa-fw fa-4x fa-seedling"></i>
|
||||
</div>
|
||||
[[post-queue:no-queued-posts]]
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-lg-2 col-12">
|
||||
<strong>[[post-queue:user]]</strong>
|
||||
<div>
|
||||
</div>
|
||||
{{{ else }}}
|
||||
<div class="alert alert-info d-flex align-items-md-center d-flex flex-column flex-md-row">
|
||||
<p class="mb-md-0">[[post-queue:no-single-post]]</p>
|
||||
<div class="d-grid ms-md-auto">
|
||||
<a class="btn btn-sm btn-primary flex-shrink text-nowrap" href=".">[[post-queue:back-to-list]]</a>
|
||||
</div>
|
||||
</div>
|
||||
{{{ end }}}
|
||||
{{{ end }}}
|
||||
|
||||
{{{ each posts }}}
|
||||
<div class="card mb-4" data-id="{./id}" data-uid="{./user.uid}">
|
||||
<div class="row g-0">
|
||||
<div class="col-lg-3 bg-card-cap rounded-start">
|
||||
<ul class="list-unstyled ps-0 mb-0 border-end h-100">
|
||||
<li class="card-body border-bottom position-relative">
|
||||
{{{ if !singlePost }}}
|
||||
<input id="{./id}" type="checkbox" class="form-check-input" autocomplete="off" />
|
||||
{{{ end }}}
|
||||
<label for="{./id}" class="small stretched-link">
|
||||
{{{ if posts.data.tid }}}[[post-queue:reply]]{{{ else }}}[[post-queue:topic]]{{{ end }}}
|
||||
</label>
|
||||
</li>
|
||||
<li class="card-body d-flex flex-column gap-1 border-bottom">
|
||||
<div class="d-flex text-xs fw-semibold align-items-center">
|
||||
[[post-queue:user]]
|
||||
{{{ if ((privileges.ban || privileges.mute) || privileges.admin:users) }}}
|
||||
<div class="ms-auto btn-group bottom-sheet">
|
||||
<button href="#" class="btn btn-ghost btn-sm ff-secondary border text-xs dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">[[global:actions]]</button>
|
||||
<ul class="dropdown-menu p-1 text-sm" role="menu">
|
||||
{{{ if privileges.view:users:info }}}
|
||||
<li><a class="dropdown-item rounded-1" href="{config.relative_path}/user/{./user.userslug}/info" role="menuitem">[[user:account-info]]</a></li>
|
||||
{{{ end }}}
|
||||
{{{ if privileges.ban }}}
|
||||
<li class="{{{ if target.user.banned }}}hidden{{{ end }}}"><a class="dropdown-item rounded-1" href="#" data-action="ban" role="menuitem">[[user:ban-account]]</a></li>
|
||||
<li class="{{{ if !target.user.banned }}}hidden{{{ end }}}"><a class="dropdown-item rounded-1" href="#" data-action="unban" role="menuitem">[[user:unban-account]]</a></li>
|
||||
{{{ end }}}
|
||||
{{{ if privileges.mute}}}
|
||||
<li class="{{{ if target.user.muted }}}hidden{{{ end }}}"><a class="dropdown-item rounded-1" href="#" data-action="mute" role="menuitem">[[user:mute-account]]</a></li>
|
||||
<li class="{{{ if !target.user.muted }}}hidden{{{ end }}}"><a class="dropdown-item rounded-1" href="#" data-action="unmute" role="menuitem">[[user:unmute-account]]</a></li>
|
||||
{{{ end }}}
|
||||
{{{ if privileges.admin:users }}}
|
||||
<li><a class="dropdown-item rounded-1" href="#" data-action="delete-account" role="menuitem">[[user:delete-account-as-admin]]</a></li>
|
||||
<li><a class="dropdown-item rounded-1" href="#" data-action="delete-content" role="menuitem">[[user:delete-content]]</a></li>
|
||||
<li><a class="dropdown-item rounded-1" href="#" data-action="delete-all" role="menuitem">[[user:delete-all]]</a></li>
|
||||
{{{ end }}}
|
||||
</ul>
|
||||
</div>
|
||||
{{{ end }}}
|
||||
</div>
|
||||
<div class="small">
|
||||
{{{ if posts.user.userslug}}}
|
||||
<a href="{config.relative_path}/uid/{posts.user.uid}">{buildAvatar(posts.user, "24px", true, "not-responsive")} {posts.user.username}</a>
|
||||
<a class="text-decoration-none" href="{config.relative_path}/uid/{posts.user.uid}">{buildAvatar(posts.user, "24px", true, "not-responsive")} {posts.user.username}</a>
|
||||
{{{ else }}}
|
||||
{posts.user.username}
|
||||
{{{ end }}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-12">
|
||||
<strong>[[post-queue:category]]{{{ if posts.data.cid}}} <i class="fa fa-fw fa-edit" data-bs-toggle="tooltip" title="[[post-queue:category-editable]]"></i>{{{ end }}}</strong>
|
||||
<div class="topic-category" {{{if posts.data.cid}}}data-editable="editable"{{{end}}}">
|
||||
<div>
|
||||
<span class="badge text-body border border-gray-300 stats text-xs">
|
||||
<span title="{posts.user.postcount}" class="fw-bold">{humanReadableNumber(posts.user.postcount)}</span>
|
||||
<span class="text-lowercase fw-normal">[[global:posts]]</span>
|
||||
</span>
|
||||
<span class="badge text-body border border-gray-300 stats text-xs">
|
||||
<span title="{posts.user.reputation}" class="fw-bold">{humanReadableNumber(posts.user.reputation)}</span>
|
||||
<span class="text-lowercase fw-normal">[[global:reputation]]</span>
|
||||
</span>
|
||||
<span class="badge text-body border border-gray-300 stats text-xs">
|
||||
<span class="text-lowercase fw-normal">[[user:joined]]</span>
|
||||
<span title="{posts.user.joindateISO}" class="timeago fw-bold"></span>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="card-body border-bottom">
|
||||
<div class="text-xs fw-semibold mb-1">[[post-queue:when]]</div>
|
||||
<span class="small timeago" title={posts.data.timestampISO}></span>
|
||||
</li>
|
||||
<li class="card-body border-bottom">
|
||||
<div class="text-xs fw-semibold mb-1">
|
||||
{{{ if posts.data.tid }}}[[post-queue:topic]]{{{ else }}}[[post-queue:title]]{{{ end }}}
|
||||
</div>
|
||||
<span class="small topic-title text-break">
|
||||
{{{ if posts.data.tid }}}
|
||||
<div class="d-flex flex-column align-items-start gap-1">
|
||||
<a href="{config.relative_path}/topic/{posts.data.tid}">{posts.topic.title}</a>
|
||||
<span class="badge text-body border border-gray-300 stats text-xs">
|
||||
<span class="text-lowercase fw-normal">[[global:lastpost]]</span>
|
||||
<span title="{posts.topic.lastposttimeISO}" class="timeago fw-bold"></span>
|
||||
</span>
|
||||
</div>
|
||||
{{{ end }}}
|
||||
<span class="title-text">{posts.data.title}</span>
|
||||
</span>
|
||||
{{{if !posts.data.tid}}}
|
||||
<div class="topic-title-editable hidden">
|
||||
<input class="form-control" type="text" value="{posts.data.title}"/>
|
||||
</div>
|
||||
{{{end}}}
|
||||
</li>
|
||||
<li class="card-body border-bottom">
|
||||
<div class="text-xs fw-semibold mb-1">
|
||||
[[post-queue:category]]
|
||||
</div>
|
||||
<div class="topic-category small">
|
||||
<a href="{config.relative_path}/category/{posts.category.slug}">
|
||||
<div class="category-item d-inline-block">
|
||||
{buildCategoryIcon(./category, "24px", "rounded-circle")}
|
||||
@@ -94,46 +160,59 @@
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-7 col-12">
|
||||
<strong>{{{ if posts.data.tid }}}[[post-queue:topic]]{{{ else }}}[[post-queue:title]] <i class="fa fa-fw fa-edit" data-bs-toggle="tooltip" title="[[post-queue:title-editable]]"></i>{{{ end }}}</strong>
|
||||
<div class="topic-title text-break">
|
||||
{{{ if posts.data.tid }}}
|
||||
<a href="{config.relative_path}/topic/{posts.data.tid}">{posts.topic.title}</a>
|
||||
</li>
|
||||
<li class="card-body">
|
||||
<div class="row row-cols-2 g-1">
|
||||
{{{ if ./canAccept }}}
|
||||
<div class="col d-grid">
|
||||
<button class="btn btn-success btn-sm" data-action="accept"><i class="fa fa-fw fa-check"></i> [[post-queue:accept]] </button>
|
||||
</div>
|
||||
<div class="col d-grid">
|
||||
<button class="btn btn-danger btn-sm" data-action="reject"><i class="fa fa-fw fa-times"></i> [[post-queue:reject]]</button>
|
||||
</div>
|
||||
{{{ end }}}
|
||||
{{{ if ./canEdit}}}
|
||||
{{{ if !posts.data.tid }}}
|
||||
<div class="col d-grid">
|
||||
<button class="btn btn-light btn-sm" data-action="editTitle"><i class="fa fa-fw fa-edit"></i> [[post-queue:title]]</button>
|
||||
</div>
|
||||
{{{ end }}}
|
||||
<div class="col d-grid">
|
||||
<button class="btn btn-light btn-sm" data-action="editContent"><i class="fa fa-fw fa-edit"></i> [[post-queue:content]]</button>
|
||||
</div>
|
||||
{{{if posts.data.cid}}}
|
||||
<div class="col d-grid">
|
||||
<button class="btn btn-light btn-sm" data-action="editCategory"><i class="fa fa-fw fa-edit"></i> [[post-queue:category]]</button>
|
||||
</div>
|
||||
{{{ end }}}
|
||||
{{{ end }}}
|
||||
{{{ if ./canAccept }}}
|
||||
<div class="col d-grid">
|
||||
<button class="btn btn-light btn-sm" data-action="notify"><i class="fa fa-fw fa-bell-o"></i> [[post-queue:notify]]</button>
|
||||
</div>
|
||||
{{{ else }}}
|
||||
<div class="col d-grid">
|
||||
<button class="btn btn-danger btn-sm" data-action="reject"><i class="fa fa-fw fa-times"></i> [[post-queue:remove]]</button>
|
||||
</div>
|
||||
{{{ end }}}
|
||||
<span data-action="editTitle" class="title-text">{posts.data.title}</span>
|
||||
</div>
|
||||
{{{if !posts.data.tid}}}
|
||||
<div class="topic-title-editable hidden">
|
||||
<input class="form-control" type="text" value="{posts.data.title}"/>
|
||||
</div>
|
||||
{{{end}}}
|
||||
</div>
|
||||
</div>
|
||||
<hr/>
|
||||
<div>
|
||||
<strong>[[post-queue:content]] <i class="fa fa-fw fa-edit" data-bs-toggle="tooltip" title="[[post-queue:content-editable]]"></i></strong>
|
||||
<div data-action="editContent" class="post-content text-break">{posts.data.content}</div>
|
||||
<div class="post-content-editable hidden">
|
||||
<textarea class="form-control w-100" style="height:300px;">{posts.data.rawContent}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-footer text-end">
|
||||
<div>
|
||||
{{{ if ./canAccept }}}
|
||||
<button class="btn btn-danger btn-sm" data-action="reject"><i class="fa fa-fw fa-times"></i> [[post-queue:reject]]</button>
|
||||
<button class="btn btn-info btn-sm" data-action="notify"><i class="fa fa-fw fa-bell-o"></i> [[post-queue:notify]]</button>
|
||||
<button class="btn btn-success btn-sm" data-action="accept"><i class="fa fa-fw fa-check"></i> [[post-queue:accept]] </button>
|
||||
{{{ else }}}
|
||||
<button class="btn btn-danger btn-sm" data-action="reject"><i class="fa fa-fw fa-times"></i> [[post-queue:remove]]</button>
|
||||
{{{ end }}}
|
||||
<div class="col-lg-9 d-flex flex-column">
|
||||
<div class="post-content mb-auto text-break p-3 pb-0 h-100">{posts.data.content}</div>
|
||||
<div class="post-content-editable flex-grow-1 hidden">
|
||||
<textarea class="form-control w-100 h-100 p-3">{posts.data.rawContent}</textarea>
|
||||
</div>
|
||||
<div component="post-queue/link-container" class="hidden border-top mx-3 py-3">
|
||||
<label class="text-secondary form-text mb-2">[[post-queue:links-in-this-post]]</label>
|
||||
<ul component="post-queue/link-container/list" class="text-sm"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{{ end }}}
|
||||
</div>
|
||||
|
||||
<!-- IMPORT partials/paginator.tpl -->
|
||||
{{{ end }}}
|
||||
</div>
|
||||
|
||||
<!-- IMPORT partials/paginator.tpl -->
|
||||
</div>
|
||||
@@ -985,15 +985,15 @@ describe('Post\'s', () => {
|
||||
assert.equal(posts[1].data.content, 'this is a queued reply');
|
||||
});
|
||||
|
||||
it('should error if data is invalid', (done) => {
|
||||
socketPosts.editQueuedContent({ uid: globalModUid }, null, (err) => {
|
||||
assert.equal(err.message, '[[error:invalid-data]]');
|
||||
done();
|
||||
});
|
||||
it('should error if data is invalid', async () => {
|
||||
await assert.rejects(
|
||||
apiPosts.editQueuedPost({ uid: globalModUid }, null),
|
||||
{ message: '[[error:invalid-data]]' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should edit post in queue', async () => {
|
||||
await socketPosts.editQueuedContent({ uid: globalModUid }, { id: queueId, content: 'newContent' });
|
||||
await apiPosts.editQueuedPost({ uid: globalModUid }, { id: queueId, content: 'newContent' });
|
||||
const { body } = await request.get(`${nconf.get('url')}/api/post-queue`, { jar });
|
||||
const { posts } = body;
|
||||
assert.equal(posts[1].type, 'reply');
|
||||
@@ -1001,7 +1001,7 @@ describe('Post\'s', () => {
|
||||
});
|
||||
|
||||
it('should edit topic title in queue', async () => {
|
||||
await socketPosts.editQueuedContent({ uid: globalModUid }, { id: topicQueueId, title: 'new topic title' });
|
||||
await apiPosts.editQueuedPost({ uid: globalModUid }, { id: topicQueueId, title: 'new topic title' });
|
||||
const { body } = await request.get(`${nconf.get('url')}/api/post-queue`, { jar });
|
||||
const { posts } = body;
|
||||
assert.equal(posts[0].type, 'topic');
|
||||
@@ -1009,39 +1009,39 @@ describe('Post\'s', () => {
|
||||
});
|
||||
|
||||
it('should edit topic category in queue', async () => {
|
||||
await socketPosts.editQueuedContent({ uid: globalModUid }, { id: topicQueueId, cid: 2 });
|
||||
await apiPosts.editQueuedPost({ uid: globalModUid }, { id: topicQueueId, cid: 2 });
|
||||
const { body } = await request.get(`${nconf.get('url')}/api/post-queue`, { jar });
|
||||
const { posts } = body;
|
||||
assert.equal(posts[0].type, 'topic');
|
||||
assert.equal(posts[0].data.cid, 2);
|
||||
await socketPosts.editQueuedContent({ uid: globalModUid }, { id: topicQueueId, cid: cid });
|
||||
await apiPosts.editQueuedPost({ uid: globalModUid }, { id: topicQueueId, cid: cid });
|
||||
});
|
||||
|
||||
it('should prevent regular users from approving posts', (done) => {
|
||||
socketPosts.accept({ uid: uid }, { id: queueId }, (err) => {
|
||||
assert.equal(err.message, '[[error:no-privileges]]');
|
||||
done();
|
||||
});
|
||||
it('should prevent regular users from approving posts', async () => {
|
||||
await assert.rejects(
|
||||
apiPosts.acceptQueuedPost({ uid: uid }, { id: queueId }),
|
||||
{ message: '[[error:no-privileges]]' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should prevent regular users from approving non existing posts', (done) => {
|
||||
socketPosts.accept({ uid: uid }, { id: 123123 }, (err) => {
|
||||
assert.equal(err.message, '[[error:no-post]]');
|
||||
done();
|
||||
});
|
||||
it('should prevent regular users from approving non existing posts', async () => {
|
||||
await assert.rejects(
|
||||
apiPosts.acceptQueuedPost({ uid: uid }, { id: 123123 }),
|
||||
{ message: '[[error:no-post]]' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept queued posts and submit', async () => {
|
||||
const ids = await db.getSortedSetRange('post:queue', 0, -1);
|
||||
await socketPosts.accept({ uid: globalModUid }, { id: ids[0] });
|
||||
await socketPosts.accept({ uid: globalModUid }, { id: ids[1] });
|
||||
await apiPosts.acceptQueuedPost({ uid: globalModUid }, { id: ids[0] });
|
||||
await apiPosts.acceptQueuedPost({ uid: globalModUid }, { id: ids[1] });
|
||||
});
|
||||
|
||||
it('should not crash if id does not exist', (done) => {
|
||||
socketPosts.reject({ uid: globalModUid }, { id: '123123123' }, (err) => {
|
||||
assert.equal(err.message, '[[error:no-post]]');
|
||||
done();
|
||||
});
|
||||
it('should not crash if id does not exist', async () => {
|
||||
await assert.rejects(
|
||||
apiPosts.removeQueuedPost({ uid: globalModUid }, { id: '123123123' }),
|
||||
{ message: '[[error:no-post]]' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should bypass post queue if user is in exempt group', async () => {
|
||||
|
||||
Reference in New Issue
Block a user