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:
Barış Uşaklı
2025-06-05 07:15:45 -04:00
committed by GitHub
parent 6d40a2118c
commit 4fbcfae8b1
13 changed files with 521 additions and 259 deletions

View File

@@ -1,7 +1,7 @@
get: get:
tags: tags:
- admin - admin
summary: Get flag data summary: Get post queue
responses: responses:
"200": "200":
description: "" description: ""
@@ -42,6 +42,8 @@ get:
description: A user identifier description: A user identifier
type: type:
type: string type: string
canEdit:
type: boolean
data: data:
type: object type: object
properties: properties:

View File

@@ -202,6 +202,10 @@ paths:
$ref: 'write/posts/pid/diffs/timestamp.yaml' $ref: 'write/posts/pid/diffs/timestamp.yaml'
/posts/{pid}/replies: /posts/{pid}/replies:
$ref: 'write/posts/pid/replies.yaml' $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/: /chats/:
$ref: 'write/chats.yaml' $ref: 'write/chats.yaml'
/chats/unread: /chats/unread:

View 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

View 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

View File

@@ -69,14 +69,10 @@ define('forum/post-queue', [
const id = textarea.parents('[data-id]').attr('data-id'); const id = textarea.parents('[data-id]').attr('data-id');
const titleEdit = triggerClass === '[data-action="editTitle"]'; const titleEdit = triggerClass === '[data-action="editTitle"]';
socket.emit('posts.editQueuedContent', { api.put(`/posts/queue/${id}`, {
id: id,
title: titleEdit ? textarea.val() : undefined, title: titleEdit ? textarea.val() : undefined,
content: titleEdit ? undefined : textarea.val(), content: titleEdit ? undefined : textarea.val(),
}, function (err, data) { }).then((data) => {
if (err) {
return alerts.error(err);
}
if (titleEdit) { if (titleEdit) {
preview.find('.title-text').text(data.postData.title); preview.find('.title-text').text(data.postData.title);
} else { } else {
@@ -85,7 +81,7 @@ define('forum/post-queue', [
textarea.parent().addClass('hidden'); textarea.parent().addClass('hidden');
preview.removeClass('hidden'); preview.removeClass('hidden');
}); }).catch(alerts.error);
}); });
} }
@@ -96,8 +92,7 @@ define('forum/post-queue', [
onSubmit: function (selectedCategory) { onSubmit: function (selectedCategory) {
Promise.all([ Promise.all([
api.get(`/categories/${selectedCategory.cid}`, {}), api.get(`/categories/${selectedCategory.cid}`, {}),
socket.emit('posts.editQueuedContent', { api.put(`/posts/queue/${id}`, {
id: id,
cid: selectedCategory.cid, cid: selectedCategory.cid,
}), }),
]).then(function (result) { ]).then(function (result) {
@@ -174,6 +169,35 @@ define('forum/post-queue', [
async function handleQueueActions() { async function handleQueueActions() {
// accept, reject, notify // 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() { function getMessage() {
return new Promise((resolve) => { return new Promise((resolve) => {
const modal = bootbox.dialog({ const modal = bootbox.dialog({
@@ -194,36 +218,16 @@ define('forum/post-queue', [
}); });
} }
const parent = $(this).parents('[data-id]'); const actionsMap = {
const action = $(this).attr('data-action'); accept: () => api.post(`/posts/queue/${id}`, {}),
const id = parent.attr('data-id'); reject: () => api.del(`/posts/queue/${id}`, {}),
const listContainer = parent.get(0).parentNode; notify: async () => api.post(`/posts/queue/${id}/notify`, { message: await getMessage() }),
};
if ((!['accept', 'reject', 'notify'].includes(action)) || if (actionsMap[action]) {
(action === 'reject' && !await confirmReject(ajaxify.data.canAccept ? '[[post-queue:confirm-reject]]' : '[[post-queue:confirm-remove]]'))) { const result = actionsMap[action]();
return; return (result instanceof Promise ? result : Promise.resolve(result));
} }
throw new Error(`Unknown action: ${action}`);
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;
} }
function handleBulkActions() { function handleBulkActions() {
@@ -244,7 +248,7 @@ define('forum/post-queue', [
return; return;
} }
const action = bulkAction.split('-')[0]; 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) { Promise.allSettled(promises).then(function (results) {
const fulfilled = results.filter(res => res.status === 'fulfilled').length; const fulfilled = results.filter(res => res.status === 'fulfilled').length;

View File

@@ -17,6 +17,8 @@ const activitypub = require('../activitypub');
const apiHelpers = require('./helpers'); const apiHelpers = require('./helpers');
const websockets = require('../socket.io'); const websockets = require('../socket.io');
const socketHelpers = require('../socket.io/helpers'); const socketHelpers = require('../socket.io/helpers');
const translator = require('../translator');
const notifications = require('../notifications');
const postsAPI = module.exports; const postsAPI = module.exports;
@@ -574,3 +576,91 @@ postsAPI.getReplies = async (caller, { pid }) => {
return postData; 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]);
}

View File

@@ -237,6 +237,7 @@ modsController.postQueue = async function (req, res, next) {
.map((post) => { .map((post) => {
const isSelf = post.user.uid === req.uid; const isSelf = post.user.uid === req.uid;
post.canAccept = !isSelf && (isAdmin || isGlobalMod || !!moderatedCids.length); post.canAccept = !isSelf && (isAdmin || isGlobalMod || !!moderatedCids.length);
post.canEdit = isSelf || isAdmin || isGlobalMod;
return post; return post;
}); });

View File

@@ -189,3 +189,24 @@ Posts.getReplies = async (req, res) => {
helpers.formatApiResponse(200, res, { replies }); 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);
};

View File

@@ -307,9 +307,11 @@ module.exports = function (Posts) {
if (data.type === 'topic') { if (data.type === 'topic') {
const result = await createTopic(data.data); const result = await createTopic(data.data);
data.pid = result.postData.pid; data.pid = result.postData.pid;
data.tid = result.topicData.tid;
} else if (data.type === 'reply') { } else if (data.type === 'reply') {
const result = await createReply(data.data); const result = await createReply(data.data);
data.pid = result.pid; data.pid = result.pid;
data.tid = result.tid;
} }
await removeFromQueue(id); await removeFromQueue(id);
plugins.hooks.fire('action:post-queue:submitFromQueue', { data: data }); plugins.hooks.fire('action:post-queue:submitFromQueue', { data: data });

View File

@@ -41,6 +41,12 @@ module.exports = function () {
setupApiRoute(router, 'get', '/:pid/replies', [middleware.assert.post], controllers.write.posts.getReplies); 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 // Shorthand route to access post routes by topic index
router.all('/+byIndex/:index*?', [middleware.checkRequired.bind(null, ['tid'])], controllers.write.posts.redirectByIndex); router.all('/+byIndex/:index*?', [middleware.checkRequired.bind(null, ['tid'])], controllers.write.posts.redirectByIndex);

View File

@@ -1,18 +1,10 @@
'use strict'; 'use strict';
const validator = require('validator');
const db = require('../database'); const db = require('../database');
const posts = require('../posts'); const posts = require('../posts');
const privileges = require('../privileges'); const privileges = require('../privileges');
const plugins = require('../plugins');
const meta = require('../meta');
const topics = require('../topics'); const topics = require('../topics');
const notifications = require('../notifications');
const utils = require('../utils'); const utils = require('../utils');
const events = require('../events');
const translator = require('../translator');
const api = require('../api'); const api = require('../api');
const sockets = require('.'); const sockets = require('.');
@@ -99,90 +91,23 @@ SocketPosts.getReplies = async function (socket, pid) {
}; };
SocketPosts.accept = async function (socket, data) { SocketPosts.accept = async function (socket, data) {
await canEditQueue(socket, data, 'accept'); sockets.warnDeprecated(socket, 'POST /api/v3/posts/queue/:id');
const result = await posts.submitFromQueue(data.id); await api.posts.acceptQueuedPost(socket, data);
if (result && socket.uid !== parseInt(result.uid, 10)) {
await sendQueueNotification('post-queue-accepted', result.uid, `/post/${result.pid}`);
}
await logQueueEvent(socket, result, 'accept');
}; };
SocketPosts.reject = async function (socket, data) { SocketPosts.reject = async function (socket, data) {
await canEditQueue(socket, data, 'reject'); sockets.warnDeprecated(socket, 'DELETE /api/v3/posts/queue/:id');
const result = await posts.removeFromQueue(data.id); await api.posts.removeQueuedPost(socket, data);
if (result && socket.uid !== parseInt(result.uid, 10)) {
await sendQueueNotification('post-queue-rejected', result.uid, '/');
}
await logQueueEvent(socket, result, 'reject');
}; };
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) { SocketPosts.notify = async function (socket, data) {
await canEditQueue(socket, data, 'notify'); sockets.warnDeprecated(socket, 'POST /api/v3/posts/queue/:id/notify');
const result = await posts.getFromQueue(data.id); await api.posts.notifyQueuedPostOwner(socket, data);
if (result) {
await sendQueueNotification('post-queue-notify', result.uid, `/post-queue/${data.id}`, validator.escape(String(data.message)));
}
}; };
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) { SocketPosts.editQueuedContent = async function (socket, data) {
if (!data || !data.id || (!data.content && !data.title && !data.cid)) { sockets.warnDeprecated(socket, 'PUT /api/v3/posts/queue/:id');
throw new Error('[[error:invalid-data]]'); return await api.posts.editQueuedPost(socket, data);
}
await posts.editQueuedContent(socket.uid, data);
if (data.content) {
return await plugins.hooks.fire('filter:parse.post', { postData: data });
}
return { postData: data };
}; };
require('../promisify')(SocketPosts); require('../promisify')(SocketPosts);

View File

@@ -1,92 +1,158 @@
{{{ if isAdmin }}} <div class="flex-fill">
{{{ if !enabled }}} {{{ if isAdmin }}}
<div class="alert alert-info"> {{{ if !enabled }}}
[[post-queue:enabling-help, {config.relative_path}/admin/settings/post#post-queue]] <div class="alert alert-info">
</div> [[post-queue:enabling-help, {config.relative_path}/admin/settings/post#post-queue]]
{{{ 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> </div>
<div class="btn-group bottom-sheet" component="post-queue/bulk-actions"> {{{ end }}}
<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"> {{{ else }}}
<i class="fa fa-clone text-primary"></i> <div>
<span class="fw-semibold">[[post-queue:bulk-actions]]</span> <p class="lead">[[post-queue:public-intro]]</p>
</button> <p>[[post-queue:public-description]]</p>
<ul class="dropdown-menu dropdown-menu-end p-1" role="menu"> <hr />
{{{ 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>
</div> </div>
</div> {{{ end }}}
<hr/> {{{ if (!singlePost && posts.length) }}}
{{{ end }}} <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="btn-group bottom-sheet" component="post-queue/bulk-actions">
<div class="col-12"> <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">
<div class="post-queue preventSlideout posts-list"> <i class="fa fa-clone text-primary"></i><span class="fw-semibold"> [[post-queue:bulk-actions]]</span>
{{{ if !posts.length }}} </button>
{{{ if !singlePost }}} <ul class="dropdown-menu p-1 text-sm dropdown-menu-end" role="menu">
<div class="mx-auto"> {{{ if canAccept }}}
<div class="d-flex flex-column gap-3 justify-content-center text-center"> <li><a class="dropdown-item rounded-1" href="#" data-action="accept-all" role="menuitem">[[post-queue:accept-all]]</a></li>
<div class="mx-auto p-4 bg-light border rounded"> <li><a class="dropdown-item rounded-1" href="#" data-action="accept-selected" role="menuitem">[[post-queue:accept-selected]]</a></li>
<i class="text-secondary fa fa-fw fa-4x fa-seedling"></i> <li class="dropdown-divider"></li>
</div> <li><a class="dropdown-item rounded-1" href="#" data-action="reject-all" role="menuitem">[[post-queue:reject-all]]</a></li>
[[post-queue:no-queued-posts]] <li><a class="dropdown-item rounded-1" href="#" data-action="reject-selected" role="menuitem">[[post-queue:reject-selected]]</a></li>
</div>
</div>
{{{ else }}} {{{ else }}}
<div class="alert alert-info d-flex align-items-md-center d-flex flex-column flex-md-row"> <li><a class="dropdown-item rounded-1" href="#" data-action="reject-all" role="menuitem">[[post-queue:remove-all]]</a></li>
<p class="mb-md-0">[[post-queue:no-single-post]]</p> <li><a class="dropdown-item rounded-1" href="#" data-action="reject-selected" role="menuitem">[[post-queue:remove-selected]]</a></li>
<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 }}}
{{{ end }}} </ul>
</div>
</div>
{{{ end }}}
{{{ each posts }}} <div class="post-queue posts-list">
<div class="card mb-3" data-id="{./id}"data-uid="{./user.uid}"> {{{ if !posts.length }}}
<div class="card-header"> {{{ if !singlePost }}}
{{{ if !singlePost }}} <div class="mx-auto">
<input type="checkbox" class="form-check-input" autocomplete="off" /> <div class="d-flex flex-column gap-3 justify-content-center text-center">
{{{ end }}} <div class="mx-auto p-4 bg-light border rounded">
<strong>{{{ if posts.data.tid }}}[[post-queue:reply]]{{{ else }}}[[post-queue:topic]]{{{ end }}}</strong> <i class="text-secondary fa fa-fw fa-4x fa-seedling"></i>
<span class="timeago float-end" title={posts.data.timestampISO}></span> </div>
[[post-queue:no-queued-posts]]
</div> </div>
<div class="card-body"> </div>
<div class="row"> {{{ else }}}
<div class="col-lg-2 col-12"> <div class="alert alert-info d-flex align-items-md-center d-flex flex-column flex-md-row">
<strong>[[post-queue:user]]</strong> <p class="mb-md-0">[[post-queue:no-single-post]]</p>
<div> <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}}} {{{ 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 }}} {{{ else }}}
{posts.user.username} {posts.user.username}
{{{ end }}} {{{ end }}}
</div> </div>
</div> <div>
<div class="col-lg-3 col-12"> <span class="badge text-body border border-gray-300 stats text-xs">
<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> <span title="{posts.user.postcount}" class="fw-bold">{humanReadableNumber(posts.user.postcount)}</span>
<div class="topic-category" {{{if posts.data.cid}}}data-editable="editable"{{{end}}}"> <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}"> <a href="{config.relative_path}/category/{posts.category.slug}">
<div class="category-item d-inline-block"> <div class="category-item d-inline-block">
{buildCategoryIcon(./category, "24px", "rounded-circle")} {buildCategoryIcon(./category, "24px", "rounded-circle")}
@@ -94,46 +160,59 @@
</div> </div>
</a> </a>
</div> </div>
</div> </li>
<div class="col-lg-7 col-12"> <li class="card-body">
<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="row row-cols-2 g-1">
<div class="topic-title text-break"> {{{ if ./canAccept }}}
{{{ if posts.data.tid }}} <div class="col d-grid">
<a href="{config.relative_path}/topic/{posts.data.tid}">{posts.topic.title}</a> <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 }}} {{{ end }}}
<span data-action="editTitle" class="title-text">{posts.data.title}</span>
</div> </div>
{{{if !posts.data.tid}}} </li>
<div class="topic-title-editable hidden"> </ul>
<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>
</div> </div>
<div class="card-footer text-end"> <div class="col-lg-9 d-flex flex-column">
<div> <div class="post-content mb-auto text-break p-3 pb-0 h-100">{posts.data.content}</div>
{{{ if ./canAccept }}} <div class="post-content-editable flex-grow-1 hidden">
<button class="btn btn-danger btn-sm" data-action="reject"><i class="fa fa-fw fa-times"></i> [[post-queue:reject]]</button> <textarea class="form-control w-100 h-100 p-3">{posts.data.rawContent}</textarea>
<button class="btn btn-info btn-sm" data-action="notify"><i class="fa fa-fw fa-bell-o"></i> [[post-queue:notify]]</button> </div>
<button class="btn btn-success btn-sm" data-action="accept"><i class="fa fa-fw fa-check"></i> [[post-queue:accept]] </button> <div component="post-queue/link-container" class="hidden border-top mx-3 py-3">
{{{ else }}} <label class="text-secondary form-text mb-2">[[post-queue:links-in-this-post]]</label>
<button class="btn btn-danger btn-sm" data-action="reject"><i class="fa fa-fw fa-times"></i> [[post-queue:remove]]</button> <ul component="post-queue/link-container/list" class="text-sm"></ul>
{{{ end }}}
</div> </div>
</div> </div>
</div> </div>
{{{ end }}}
</div> </div>
{{{ end }}}
<!-- IMPORT partials/paginator.tpl -->
</div> </div>
<!-- IMPORT partials/paginator.tpl -->
</div> </div>

View File

@@ -985,15 +985,15 @@ describe('Post\'s', () => {
assert.equal(posts[1].data.content, 'this is a queued reply'); assert.equal(posts[1].data.content, 'this is a queued reply');
}); });
it('should error if data is invalid', (done) => { it('should error if data is invalid', async () => {
socketPosts.editQueuedContent({ uid: globalModUid }, null, (err) => { await assert.rejects(
assert.equal(err.message, '[[error:invalid-data]]'); apiPosts.editQueuedPost({ uid: globalModUid }, null),
done(); { message: '[[error:invalid-data]]' },
}); );
}); });
it('should edit post in queue', async () => { 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 { body } = await request.get(`${nconf.get('url')}/api/post-queue`, { jar });
const { posts } = body; const { posts } = body;
assert.equal(posts[1].type, 'reply'); assert.equal(posts[1].type, 'reply');
@@ -1001,7 +1001,7 @@ describe('Post\'s', () => {
}); });
it('should edit topic title in queue', async () => { 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 { body } = await request.get(`${nconf.get('url')}/api/post-queue`, { jar });
const { posts } = body; const { posts } = body;
assert.equal(posts[0].type, 'topic'); assert.equal(posts[0].type, 'topic');
@@ -1009,39 +1009,39 @@ describe('Post\'s', () => {
}); });
it('should edit topic category in queue', async () => { 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 { body } = await request.get(`${nconf.get('url')}/api/post-queue`, { jar });
const { posts } = body; const { posts } = body;
assert.equal(posts[0].type, 'topic'); assert.equal(posts[0].type, 'topic');
assert.equal(posts[0].data.cid, 2); 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) => { it('should prevent regular users from approving posts', async () => {
socketPosts.accept({ uid: uid }, { id: queueId }, (err) => { await assert.rejects(
assert.equal(err.message, '[[error:no-privileges]]'); apiPosts.acceptQueuedPost({ uid: uid }, { id: queueId }),
done(); { message: '[[error:no-privileges]]' },
}); );
}); });
it('should prevent regular users from approving non existing posts', (done) => { it('should prevent regular users from approving non existing posts', async () => {
socketPosts.accept({ uid: uid }, { id: 123123 }, (err) => { await assert.rejects(
assert.equal(err.message, '[[error:no-post]]'); apiPosts.acceptQueuedPost({ uid: uid }, { id: 123123 }),
done(); { message: '[[error:no-post]]' },
}); );
}); });
it('should accept queued posts and submit', async () => { it('should accept queued posts and submit', async () => {
const ids = await db.getSortedSetRange('post:queue', 0, -1); const ids = await db.getSortedSetRange('post:queue', 0, -1);
await socketPosts.accept({ uid: globalModUid }, { id: ids[0] }); await apiPosts.acceptQueuedPost({ uid: globalModUid }, { id: ids[0] });
await socketPosts.accept({ uid: globalModUid }, { id: ids[1] }); await apiPosts.acceptQueuedPost({ uid: globalModUid }, { id: ids[1] });
}); });
it('should not crash if id does not exist', (done) => { it('should not crash if id does not exist', async () => {
socketPosts.reject({ uid: globalModUid }, { id: '123123123' }, (err) => { await assert.rejects(
assert.equal(err.message, '[[error:no-post]]'); apiPosts.removeQueuedPost({ uid: globalModUid }, { id: '123123123' }),
done(); { message: '[[error:no-post]]' },
}); );
}); });
it('should bypass post queue if user is in exempt group', async () => { it('should bypass post queue if user is in exempt group', async () => {