diff --git a/public/openapi/write.yaml b/public/openapi/write.yaml index 4a4bb9d1fe..8c5fb9c4eb 100644 --- a/public/openapi/write.yaml +++ b/public/openapi/write.yaml @@ -132,6 +132,10 @@ paths: $ref: 'write/flags.yaml' /flags/{flagId}: $ref: 'write/flags/flagId.yaml' + /flags/{flagId}/notes: + $ref: 'write/flags/flagId/notes.yaml' + /flags/{flagId}/notes/{datetime}: + $ref: 'write/flags/flagId/notes/datetime.yaml' /admin/settings/{setting}: $ref: 'write/admin/settings/setting.yaml' /admin/analytics/{set}: diff --git a/public/openapi/write/flags/flagId/notes.yaml b/public/openapi/write/flags/flagId/notes.yaml new file mode 100644 index 0000000000..46b95cf02d --- /dev/null +++ b/public/openapi/write/flags/flagId/notes.yaml @@ -0,0 +1,42 @@ +post: + tags: + - flags + summary: append a flag note + description: This operation append a shared note for a given flag. It is only available to privileged users (that is, moderators, global moderators, and administrators). + parameters: + - in: path + name: flagId + schema: + type: number + required: true + description: a valid flag id + example: 1 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + note: + type: string + example: 'test note' + datetime: + type: number + example: 1626446956652 + required: + - note + responses: + '200': + description: flag note successfully added or updated + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../components/schemas/Status.yaml#/Status + response: + allOf: + - $ref: ../../../components/schemas/FlagObject.yaml#/FlagNotesObject + - $ref: ../../../components/schemas/FlagObject.yaml#/FlagHistoryObject \ No newline at end of file diff --git a/public/openapi/write/flags/flagId/notes/datetime.yaml b/public/openapi/write/flags/flagId/notes/datetime.yaml new file mode 100644 index 0000000000..58bd67c8c1 --- /dev/null +++ b/public/openapi/write/flags/flagId/notes/datetime.yaml @@ -0,0 +1,34 @@ +delete: + tags: + - flags + summary: delete a flag note + description: This operation deletes a shared note for a given flag. It is only available to privileged users (that is, moderators, global moderators, and administrators). + parameters: + - in: path + name: flagId + schema: + type: number + required: true + description: a valid flag id + example: 1 + - in: path + name: datetime + schema: + type: number + required: true + description: A valid UNIX timestamp + example: 1626446956652 + responses: + '200': + description: Flag note deleted + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../../components/schemas/Status.yaml#/Status + response: + allOf: + - $ref: ../../../../components/schemas/FlagObject.yaml#/FlagNotesObject + - $ref: ../../../../components/schemas/FlagObject.yaml#/FlagHistoryObject \ No newline at end of file diff --git a/src/api/flags.js b/src/api/flags.js index fc681ceeb0..19a8887260 100644 --- a/src/api/flags.js +++ b/src/api/flags.js @@ -37,3 +37,44 @@ flagsApi.update = async (caller, data) => { await flags.update(flagId, caller.uid, data); return await flags.getHistory(flagId); }; + +flagsApi.appendNote = async (caller, data) => { + const allowed = await user.isPrivileged(caller.uid); + if (!allowed) { + throw new Error('[[error:no-privileges]]'); + } + if (data.datetime && data.flagId) { + try { + const note = await flags.getNote(data.flagId, data.datetime); + if (note.uid !== caller.uid) { + throw new Error('[[error:no-privileges]]'); + } + } catch (e) { + // Okay if not does not exist in database + if (!e.message === '[[error:invalid-data]]') { + throw e; + } + } + } + await flags.appendNote(data.flagId, caller.uid, data.note, data.datetime); + const [notes, history] = await Promise.all([ + flags.getNotes(data.flagId), + flags.getHistory(data.flagId), + ]); + return { notes: notes, history: history }; +}; + +flagsApi.deleteNote = async (caller, data) => { + const note = await flags.getNote(data.flagId, data.datetime); + if (note.uid !== caller.uid) { + throw new Error('[[error:no-privileges]]'); + } + + await flags.deleteNote(data.flagId, data.datetime); + + const [notes, history] = await Promise.all([ + flags.getNotes(data.flagId), + flags.getHistory(data.flagId), + ]); + return { notes: notes, history: history }; +}; diff --git a/src/controllers/write/flags.js b/src/controllers/write/flags.js index a31050e49b..7c290aa6da 100644 --- a/src/controllers/write/flags.js +++ b/src/controllers/write/flags.js @@ -29,3 +29,21 @@ Flags.update = async (req, res) => { helpers.formatApiResponse(200, res, { history }); }; + +Flags.appendNote = async (req, res) => { + console.log('appending note', req.params, req.body); + const payload = await api.flags.appendNote(req, { + flagId: req.params.flagId, + ...req.body, + }); + + helpers.formatApiResponse(200, res, payload); +}; + +Flags.deleteNote = async (req, res) => { + const payload = await api.flags.deleteNote(req, { + ...req.params, + }); + + helpers.formatApiResponse(200, res, payload); +}; diff --git a/src/flags.js b/src/flags.js index 39af483173..b5e62c2392 100644 --- a/src/flags.js +++ b/src/flags.js @@ -702,7 +702,14 @@ Flags.appendHistory = async function (flagId, uid, changeset) { Flags.appendNote = async function (flagId, uid, note, datetime) { if (datetime) { - await Flags.deleteNote(flagId, datetime); + try { + await Flags.deleteNote(flagId, datetime); + } catch (e) { + // Do not throw if note doesn't exist + if (!e.message === '[[error:invalid-data]]') { + throw e; + } + } } datetime = datetime || Date.now(); diff --git a/src/routes/write/flags.js b/src/routes/write/flags.js index b354f4d7f6..783fa177d4 100644 --- a/src/routes/write/flags.js +++ b/src/routes/write/flags.js @@ -16,5 +16,8 @@ module.exports = function () { setupApiRoute(router, 'get', '/:flagId', [...middlewares, middleware.assert.flag], controllers.write.flags.get); setupApiRoute(router, 'put', '/:flagId', [...middlewares, middleware.assert.flag], controllers.write.flags.update); + setupApiRoute(router, 'post', '/:flagId/notes', [...middlewares, middleware.assert.flag], controllers.write.flags.appendNote); + setupApiRoute(router, 'delete', '/:flagId/notes/:datetime', [...middlewares, middleware.assert.flag], controllers.write.flags.deleteNote); + return router; }; diff --git a/src/socket.io/flags.js b/src/socket.io/flags.js index 481fae3936..8f88575eb5 100644 --- a/src/socket.io/flags.js +++ b/src/socket.io/flags.js @@ -34,25 +34,12 @@ SocketFlags.update = async function (socket, data) { }; SocketFlags.appendNote = async function (socket, data) { + sockets.warnDeprecated(socket, 'POST /api/v3/flags/:flagId/notes'); if (!data || !(data.flagId && data.note)) { throw new Error('[[error:invalid-data]]'); } - const allowed = await user.isPrivileged(socket.uid); - if (!allowed) { - throw new Error('[[error:no-privileges]]'); - } - if (data.datetime && data.flagId) { - const note = await flags.getNote(data.flagId, data.datetime); - if (note.uid !== socket.uid) { - throw new Error('[[error:no-privileges]]'); - } - } - await flags.appendNote(data.flagId, socket.uid, data.note, data.datetime); - const [notes, history] = await Promise.all([ - flags.getNotes(data.flagId), - flags.getHistory(data.flagId), - ]); - return { notes: notes, history: history }; + + return await api.flags.appendNote(socket, data); }; SocketFlags.deleteNote = async function (socket, data) { @@ -60,18 +47,7 @@ SocketFlags.deleteNote = async function (socket, data) { throw new Error('[[error:invalid-data]]'); } - const note = await flags.getNote(data.flagId, data.datetime); - if (note.uid !== socket.uid) { - throw new Error('[[error:no-privileges]]'); - } - - await flags.deleteNote(data.flagId, data.datetime); - - const [notes, history] = await Promise.all([ - flags.getNotes(data.flagId), - flags.getHistory(data.flagId), - ]); - return { notes: notes, history: history }; + return await api.flags.deleteNote(socket, data); }; require('../promisify')(SocketFlags); diff --git a/test/api.js b/test/api.js index 9f08a52c67..ab7c724695 100644 --- a/test/api.js +++ b/test/api.js @@ -167,7 +167,8 @@ describe('API', async () => { mocks.delete['/posts/{pid}/diffs/{timestamp}'][1].example = (await posts.diffs.list(unprivTopic.postData.pid))[0]; // Create a sample flag - await flags.create('post', 1, unprivUid, 'sample reasons', Date.now()); + const { flagId } = await flags.create('post', 1, unprivUid, 'sample reasons', Date.now()); + await flags.appendNote(flagId, 1, 'test note', 1626446956652); // Create a new chat room await messaging.newRoom(1, [2]); diff --git a/test/flags.js b/test/flags.js index 071e0fd6c5..58ef8ba102 100644 --- a/test/flags.js +++ b/test/flags.js @@ -623,6 +623,12 @@ describe('Flags', () => { done(); }); }); + + it('should insert a note in the past if a datetime is passed in', async () => { + await Flags.appendNote(1, 1, 'this is the first note', 1626446956652); + const note = (await db.getSortedSetRange('flag:1:notes', 0, 0)).pop(); + assert.strictEqual('[1,"this is the first note"]', note); + }); }); describe('.getNotes()', () => { @@ -697,7 +703,7 @@ describe('Flags', () => { }); }); - describe('(websockets)', () => { + describe('(v3 API)', () => { const SocketFlags = require('../src/socket.io/flags'); let pid; let tid; @@ -838,23 +844,48 @@ describe('Flags', () => { }); describe('.appendNote()', () => { - it('should append a note to the flag', (done) => { - SocketFlags.appendNote({ uid: 2 }, { - flagId: 2, - note: 'lorem ipsum dolor sit amet', - }, (err, data) => { - assert.ifError(err); - assert(data.hasOwnProperty('notes')); - assert(Array.isArray(data.notes)); - assert.strictEqual('lorem ipsum dolor sit amet', data.notes[0].content); - assert.strictEqual(2, data.notes[0].uid); - - assert(data.hasOwnProperty('history')); - assert(Array.isArray(data.history)); - assert.strictEqual(1, Object.keys(data.history[0].fields).length); - assert(data.history[0].fields.hasOwnProperty('notes')); - done(); + it('should append a note to the flag', async () => { + const { response } = await request({ + method: 'post', + uri: `${nconf.get('url')}/api/v3/flags/2/notes`, + jar, + headers: { + 'x-csrf-token': csrfToken, + }, + body: { + note: 'lorem ipsum dolor sit amet', + datetime: 1626446956652, + }, + json: true, }); + + assert(response.hasOwnProperty('notes')); + assert(Array.isArray(response.notes)); + assert.strictEqual('lorem ipsum dolor sit amet', response.notes[0].content); + assert.strictEqual(2, response.notes[0].uid); + + assert(response.hasOwnProperty('history')); + assert(Array.isArray(response.history)); + assert.strictEqual(1, Object.keys(response.history[response.history.length - 1].fields).length); + assert(response.history[response.history.length - 1].fields.hasOwnProperty('notes')); + }); + }); + + describe('.deleteNote()', () => { + it('should delete a note from a flag', async () => { + const { response } = await request({ + method: 'delete', + uri: `${nconf.get('url')}/api/v3/flags/2/notes/1626446956652`, + jar, + headers: { + 'x-csrf-token': csrfToken, + }, + json: true, + }); + + assert(Array.isArray(response.history)); + assert(Array.isArray(response.notes)); + assert.strictEqual(response.notes.length, 0); }); }); });