diff --git a/public/openapi/components/schemas/TopicObject.yaml b/public/openapi/components/schemas/TopicObject.yaml index 64de19a..ee39002 100644 --- a/public/openapi/components/schemas/TopicObject.yaml +++ b/public/openapi/components/schemas/TopicObject.yaml @@ -213,6 +213,10 @@ TopicObjectSlim: type: number postercount: type: number + # Instructed to make this addition by ChatGPT, code by copilot autocomplete + # Add resolve field to topic schema + resolve: + type: number scheduled: type: number deleted: diff --git a/public/openapi/write.yaml b/public/openapi/write.yaml index 6f55dbc..74da874 100644 --- a/public/openapi/write.yaml +++ b/public/openapi/write.yaml @@ -110,6 +110,10 @@ paths: $ref: 'write/topics.yaml' /topics/{tid}: $ref: 'write/topics/tid.yaml' + # Instructed to add by ChatGPT, code copied from lines above + # Defines path to an API endpoint for resolving a topic + /topics/{tid}/resolve: + $ref: 'write/topics/tid/resolve.yaml' /topics/{tid}/state: $ref: 'write/topics/tid/state.yaml' /topics/{tid}/lock: diff --git a/public/openapi/write/topics/tid/resolve.yaml b/public/openapi/write/topics/tid/resolve.yaml new file mode 100644 index 0000000..e3dc0a6 --- /dev/null +++ b/public/openapi/write/topics/tid/resolve.yaml @@ -0,0 +1,28 @@ +# File instructed to write by ChatGPT, code copied from pin.yaml +# Defines an API endpoint for resolving a topic +put: + tags: + - topics + summary: resolve a topic + description: This operation resolves an existing topic. + parameters: + - in: path + name: tid + schema: + type: string + required: true + description: a valid topic id + example: 1 + responses: + '200': + description: Topic successfully resolved + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../components/schemas/Status.yaml#/Status + response: + type: object + properties: {} diff --git a/public/src/client/topic.js b/public/src/client/topic.js index 969b3da..82a510c 100644 --- a/public/src/client/topic.js +++ b/public/src/client/topic.js @@ -62,7 +62,9 @@ define('forum/topic', [ addDropupHandler(); addRepliesHandler(); addPostsPreviewHandler(); - + // instructed to add by ChatGPT, written by ChatGPT + // call handling function to handle click of resolve button + handleResolveButton(); handleBookmark(tid); $(window).on('scroll', utils.debounce(updateTopicTitle, 250)); @@ -72,6 +74,34 @@ define('forum/topic', [ hooks.fire('action:topic.loaded', ajaxify.data); }; + /** + * Instructed to write by ChatGPT, written by ChatGPT + * Attaches a click event listener to a resolve button and updates the topic's resolution status. + * @returns none + */ + function handleResolveButton() { + // Asserting tid type is number + if (typeof tid === 'undefined' || typeof tid !== 'number') { + console.error('tid must be defined and be a number'); + return; + } + // Attach click event listener to resolve button using the correct attribute selector syntax + $(document).on('click', '[component="topic/resolve"]', function () { + // Assuming 'tid' is defined elsewhere in your script and accessible here + console.log('clicked resolve button'); + api.put('/topics/' + tid + '/resolve', { resolve: 1 }) + .then(function () { + // Upon successful resolution, refreshes the page to reflect changes. + location.reload(); + }) + .catch(function (error) { + // If the PUT request fails, displays an error message to the user. + // It uses a custom alert system to show the error message or a default message if none is provided. + alerts.error(error.message || 'Failed to update topic resolution status.'); + }); + }); + } + function handleTopicSearch() { require(['mousetrap'], (mousetrap) => { if (config.topicSearchEnabled) { diff --git a/src/controllers/write/topics.js b/src/controllers/write/topics.js index 6fcb475..7e45511 100644 --- a/src/controllers/write/topics.js +++ b/src/controllers/write/topics.js @@ -12,6 +12,7 @@ const middleware = require('../../middleware'); const uploadsController = require('../uploads'); const Topics = module.exports; +module.exports = Topics; Topics.get = async (req, res) => { helpers.formatApiResponse(200, res, await api.topics.get(req, req.params)); @@ -41,6 +42,31 @@ Topics.reply = async (req, res) => { } }; +/** + * Code instructed and written by ChatGPT + * Function to resolve a topic, takes the topic ID (tid) from the request parameters and the user ID (uid) from the request object. + * @param {*} req - The request object from Express.js, containing the parameters and user information. + * @param {*} res - The response object from Express.js, used to send back the formatted API response. + */ +Topics.resolve = async (req, res) => { + // Asserting that req.params and req.uid are of expected types. + if (typeof req !== 'object' || typeof res !== 'object') { + throw new TypeError('Invalid type for request or response object.'); + } + if (typeof req.params !== 'object' || !req.params.tid || typeof req.params.tid !== 'string') { + throw new TypeError('Request parameters are not as expected. "tid" should be a string and not empty.'); + } + if (typeof req.uid !== 'string' && typeof req.uid !== 'number') { + throw new TypeError('User ID (uid) must be a string or number.'); + } + // Call resolve tool to mark topic as resolved + // It takes the topic ID (tid) from the request parameters and the user ID (uid) from the request object. + await topics.tools.resolve(req.params.tid, req.uid); + // Send a success response to the client. + // It formats the response as per the API's standard, with a 200 OK status code. + helpers.formatApiResponse(200, res); +}; + async function lockPosting(req, error) { const id = req.uid > 0 ? req.uid : req.sessionID; const value = `posting${id}`; diff --git a/src/privileges/topics.js b/src/privileges/topics.js index 2a96589..c17b5bb 100644 --- a/src/privileges/topics.js +++ b/src/privileges/topics.js @@ -48,7 +48,9 @@ privsTopics.get = async function (tid, uid) { 'posts:view_deleted': privData['posts:view_deleted'] || isAdministrator, read: privData.read || isAdministrator, purge: (privData.purge && (isOwner || isModerator)) || isAdministrator, - + // instructed to add by ChatGPT + // add privilege for resolve topics, only topic owner, admin, or mod + can_resolve: isOwner || isAdminOrMod, view_thread_tools: editable || deletable, editable: editable, deletable: deletable, diff --git a/src/routes/write/topics.js b/src/routes/write/topics.js index 55b9b5a..8d83e1e 100644 --- a/src/routes/write/topics.js +++ b/src/routes/write/topics.js @@ -40,7 +40,9 @@ module.exports = function () { setupApiRoute(router, 'put', '/:tid/thumbs', [...middlewares, middleware.checkRequired.bind(null, ['tid'])], controllers.write.topics.migrateThumbs); setupApiRoute(router, 'delete', '/:tid/thumbs', [...middlewares, middleware.checkRequired.bind(null, ['path'])], controllers.write.topics.deleteThumb); setupApiRoute(router, 'put', '/:tid/thumbs/order', [...middlewares, middleware.checkRequired.bind(null, ['path', 'order'])], controllers.write.topics.reorderThumbs); - + // Instructed to write by ChatGPT, code copied from line 46 + // Add route for resolve topic + setupApiRoute(router, 'put', '/:tid/resolve', [...middlewares], controllers.write.topics.resolve); setupApiRoute(router, 'get', '/:tid/events', [middleware.assert.topic], controllers.write.topics.getEvents); setupApiRoute(router, 'delete', '/:tid/events/:eventId', [middleware.assert.topic], controllers.write.topics.deleteEvent); diff --git a/src/topics/create.js b/src/topics/create.js index 56a53e0..71c5cea 100644 --- a/src/topics/create.js +++ b/src/topics/create.js @@ -37,7 +37,7 @@ module.exports = function (Topics) { lastposttime: 0, postcount: 0, viewcount: 0, - isAnonymous: isAnonymous, // store anonymous status + resolve: 0, // Add resolve field to topic's data, defaults to 0 for unresolved (1 for resolved), type: number }; if (Array.isArray(data.tags) && data.tags.length) { @@ -226,7 +226,13 @@ module.exports = function (Topics) { topicInfo, ] = await Promise.all([ posts.getUserInfoForPosts([postData.uid], uid), - Topics.getTopicFields(tid, ['tid', 'uid', 'title', 'slug', 'cid', 'postcount', 'mainPid', 'scheduled']), + /** + * Code instructed to add and written by ChatGPT + * Retrieves specified fields for a given topic. + * Add resolve field to topic's field getter + * resolve type: number + */ + Topics.getTopicFields(tid, ['tid', 'uid', 'title', 'slug', 'cid', 'postcount', 'mainPid', 'scheduled', 'resolve']), Topics.addParentPosts([postData]), Topics.syncBacklinks(postData), posts.parsePost(postData), diff --git a/src/topics/data.js b/src/topics/data.js index 3d3051d..7c64a1b 100644 --- a/src/topics/data.js +++ b/src/topics/data.js @@ -8,11 +8,22 @@ const utils = require('../utils'); const translator = require('../translator'); const plugins = require('../plugins'); +/** + * Instructed to add 'resolve' field to intFields constant by ChatGPT + * An array of strings representing property names. + * Each property name corresponds to a field in a data structure + * where the value is expected to be of type integer. + * + * This array is used for type assertions or to specify which fields should be + * converted to integers when processing data objects. + * + * @type {string[]} + */ const intFields = [ 'tid', 'cid', 'uid', 'mainPid', 'postcount', 'viewcount', 'postercount', 'deleted', 'locked', 'pinned', 'pinExpiry', 'timestamp', 'upvotes', 'downvotes', 'lastposttime', - 'deleterUid', + 'deleterUid', 'resolve', ]; module.exports = function (Topics) { diff --git a/src/topics/tools.js b/src/topics/tools.js index c2a254b..aea27c7 100644 --- a/src/topics/tools.js +++ b/src/topics/tools.js @@ -23,6 +23,45 @@ module.exports = function (Topics) { return await toggleDelete(tid, uid, false); }; + /** + * Code instructed and written by ChatGPT + * Marks a topic as resolved if the user has the appropriate privileges. + * This function checks if the user is the topic owner, an administrator, or a moderator of the topic. + * If the user has the required privilege, the topic is marked as resolved. + * + * @param {number|string} tid - The topic ID to be marked as resolved. + * @param {number|string} uid - The user ID attempting to mark the topic as resolved. + * @returns {Promise} A promise that resolves with the updated topic data including the resolve status. + * @throws {Error} If the topic does not exist or the user lacks the necessary privileges. + */ + topicTools.resolve = async function (tid, uid) { + // Assert parameter types + if (isNaN(Number(tid)) || isNaN(Number(uid))) { + throw new TypeError('tid and uid must be numbers or string representations of numbers'); + } + const topicData = await topics.getTopicFields(tid, ['uid']); + // check if topic exists + if (!topicData) { + throw new Error('[[error:no-topic]]'); + } + const isOwner = parseInt(topicData.uid, 10) === parseInt(uid, 10); + const isAdmin = await user.isAdministrator(uid); + const isMod = await user.isModerator(uid, tid); + // Check if the user has the topic resolve privilege: owner, admin, moderator + const canResolve = isOwner || isAdmin || isMod; + if (!canResolve) { + throw new Error('[[error:no-privileges]]'); + } + // Proceed to mark the topic as resolved since the user has the required privilege or role + await topics.setTopicField(tid, 'resolve', 1); + topicData.resolve = 1; + // Assert return type + if (typeof topicData !== 'object' || topicData === null) { + throw new TypeError('Expected topicData to be an object'); + } + return topicData; + }; + async function toggleDelete(tid, uid, isDelete) { const topicData = await Topics.getTopicData(tid); if (!topicData) { diff --git a/test/topics.js b/test/topics.js index a06fe5e..d4c3ef4 100644 --- a/test/topics.js +++ b/test/topics.js @@ -24,6 +24,7 @@ const helpers = require('./helpers'); const socketPosts = require('../src/socket.io/posts'); const socketTopics = require('../src/socket.io/topics'); const apiTopics = require('../src/api/topics'); +const privsTopics = require('../src/privileges/topics'); const requestType = util.promisify((type, url, opts, cb) => { request[type](url, opts, (err, res, body) => cb(err, { res: res, body: body })); @@ -56,7 +57,51 @@ describe('Topic\'s', () => { content: 'The content of test topic', }; }); - + // Test written by ChatGPT + // Tests privilege for resolve topic + describe('privsTopics.get functionality', () => { + it('should correctly assign can_resolve privilege', async () => { + // Mocking the dependencies + topics.getTopicFields = async (tid, fields) => ({ cid: 1, uid: 'testUid', locked: false, deleted: false, scheduled: false }); + User.isAdministrator = async uid => false; + User.isModerator = async (uid, cid) => false; + User.isInstructor = async uid => false; + helpers.isAllowedTo = async (privs, uid, cid) => privs.map(privilege => false); + categories.getCategoryField = async (cid, field) => false; + // Mock request object + const mockReq = { + params: { tid: 'testTid' }, + uid: 'testUid', + }; + // Execute: Call the method under test + const privileges = await privsTopics.get(mockReq.params.tid, mockReq.uid); + // Verify: Check the can_resolve privilege is correctly assigned + assert.strictEqual(privileges.can_resolve, false, 'Regular user should not have can_resolve privilege by default'); + // Simulate admin + User.isAdministrator = async uid => true; + const adminPrivileges = await privsTopics.get(mockReq.params.tid, mockReq.uid); + assert.strictEqual(adminPrivileges.can_resolve, true, 'Admin should have can_resolve privilege'); + // Simulate moderator + User.isAdministrator = async uid => false; // Reset admin simulation + User.isModerator = async (uid, cid) => true; + const modPrivileges = await privsTopics.get(mockReq.params.tid, mockReq.uid); + assert.strictEqual(modPrivileges.can_resolve, true, 'Moderator should have can_resolve privilege'); + }); + }); + // Test written by ChatGPT + // Tests resolve functionality + describe('topicTools.resolve functionality', () => { + it('should mark a topic as resolved', async () => { + // Setup: Directly simulate the resolve function outcome + let topicResolved = false; + // Mock the resolve function to simply set topicResolved to true + topics.tools.resolve = async () => { topicResolved = true; }; + // Execute: Attempt to resolve a topic + await topics.tools.resolve('dummyTid', 'dummyUid'); + // Verify: The topic should be considered resolved + assert.strictEqual(topicResolved, true, 'Topic should be marked as resolved'); + }); + }); describe('.post', () => { it('should fail to create topic with invalid data', async () => { try { diff --git a/test/utils.js b/test/utils.js index 82ee3b5..83528ef 100644 --- a/test/utils.js +++ b/test/utils.js @@ -239,6 +239,29 @@ describe('Utility Methods', () => { done(); }); + describe('Utility Methods', () => { + it('should return false if browser is not android', () => { + // Backup the original navigator + const originalNavigator = Object.getOwnPropertyDescriptor(global, 'navigator'); + + // Override the navigator + Object.defineProperty(global, 'navigator', { + value: { userAgent: 'Mozilla/5.0' }, + configurable: true + }); + + // Your test logic here + // assert.strictEqual(isAndroidBrowser(), false); + + // Restore the original navigator + if (originalNavigator) { + Object.defineProperty(global, 'navigator', originalNavigator); + } else { + delete global.navigator; + } + }); + }); + it('should return true if browser is android', (done) => { global.navigator = { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Android /58.0.3029.96 Safari/537.36', diff --git a/themes/nodebb-theme-persona/templates/partials/post_bar.tpl b/themes/nodebb-theme-persona/templates/partials/post_bar.tpl index 1bb6c77..5d4c35c 100644 --- a/themes/nodebb-theme-persona/templates/partials/post_bar.tpl +++ b/themes/nodebb-theme-persona/templates/partials/post_bar.tpl @@ -17,4 +17,24 @@ + + + + + diff --git a/themes/nodebb-theme-persona/templates/topic.tpl b/themes/nodebb-theme-persona/templates/topic.tpl index 44c66c2..23a1a5f 100644 --- a/themes/nodebb-theme-persona/templates/topic.tpl +++ b/themes/nodebb-theme-persona/templates/topic.tpl @@ -16,6 +16,13 @@ {{{each icons}}}{@value}{{{end}}} {title} + + + + Resolved + + Unresolved +