diff --git a/test/api/unit/middlewares/ensureAccessRight.test.js b/test/api/unit/middlewares/ensureAccessRight.test.js index 30eb84b333..d163ae704d 100644 --- a/test/api/unit/middlewares/ensureAccessRight.test.js +++ b/test/api/unit/middlewares/ensureAccessRight.test.js @@ -5,7 +5,7 @@ import { generateNext, } from '../../../helpers/api-unit.helper'; import i18n from '../../../../website/common/script/i18n'; -import { ensureAdmin, ensureSudo } from '../../../../website/server/middlewares/ensureAccessRight'; +import { ensureAdmin, ensureSudo, ensureNewsPoster } from '../../../../website/server/middlewares/ensureAccessRight'; import { NotAuthorized } from '../../../../website/server/libs/errors'; import apiError from '../../../../website/server/libs/apiError'; @@ -40,6 +40,27 @@ describe('ensure access middlewares', () => { }); }); + context('ensure newsPoster', () => { + it('returns not authorized when user is not a newsPoster', () => { + res.locals = { user: { contributor: { newsPoster: false } } }; + + ensureNewsPoster(req, res, next); + + const calledWith = next.getCall(0).args; + expect(calledWith[0].message).to.equal(apiError('noNewsPosterAccess')); + expect(calledWith[0] instanceof NotAuthorized).to.equal(true); + }); + + it('passes when user is a newsPoster', () => { + res.locals = { user: { contributor: { newsPoster: true } } }; + + ensureNewsPoster(req, res, next); + + expect(next).to.be.calledOnce; + expect(next.args[0]).to.be.empty; + }); + }); + context('ensure sudo', () => { it('returns not authorized when user is not a sudo user', () => { res.locals = { user: { contributor: { sudo: false } } }; diff --git a/test/api/unit/models/newsPost.js b/test/api/unit/models/newsPost.js new file mode 100644 index 0000000000..883ffe193f --- /dev/null +++ b/test/api/unit/models/newsPost.js @@ -0,0 +1,138 @@ +import { v4 } from 'uuid'; +import { model as NewsPost, refreshNewsPost } from '../../../../website/server/models/newsPost'; +import { sleep } from '../../../helpers/api-unit.helper'; + +describe('NewsPost Model', () => { + const publishDate = Number(new Date()); + + // NOTE publishDate is manually increased by +500 for each test + // to make sure it's always in the future from the previous one + // bevause NewsPost.lastNewsPost() is not reset between tests. + // And without a more recent publishDate it wouldn't update + + it('#lastNewsPost', () => { + const lastPost = { _id: v4(), publishDate, published: true }; + NewsPost.updateLastNewsPost(lastPost); + expect(NewsPost.lastNewsPost()).to.equal(lastPost); + }); + + it('#getLastPostFromDatabase', async () => { + const expectedId = v4(); + + await NewsPost.create([ + // more recent but not published + { + _id: v4(), + publishDate: new Date(publishDate + 50), + author: v4(), + published: false, + title: 'Title', + credits: 'credits', + text: 'text', + }, + // expected + { + _id: expectedId, + publishDate, + author: v4(), + published: true, + title: 'Title', + credits: 'credits', + text: 'text', + }, + // published but less recent + { + _id: v4(), + publishDate: new Date(Number(publishDate) - 50), + author: v4(), + published: true, + title: 'Title', + credits: 'credits', + text: 'text', + }, + ]); + + const fetched = await NewsPost.getLastPostFromDatabase(); + expect(fetched._id).to.equal(expectedId); + }); + + context('#updateLastNewsPost', () => { + it('updates the post if new one is more recent and published', () => { + const previousPost = { + _id: v4(), + publishDate: new Date(publishDate + 100), + published: true, + }; + NewsPost.updateLastNewsPost(previousPost); + const newPost = { + _id: v4(), + publishDate: new Date(publishDate + 150), + published: true, + }; + NewsPost.updateLastNewsPost(newPost); + expect(NewsPost.lastNewsPost()._id).to.equal(newPost._id); + }); + + it('does not update the post if new one is from the past', () => { + const previousPost = new NewsPost({ + _id: v4(), publishDate: new Date(publishDate + 200), published: true, + }); + NewsPost.updateLastNewsPost(previousPost); + const newPost = new NewsPost({ + _id: v4(), publishDate: new Date(publishDate + 175), published: true, + }); + NewsPost.updateLastNewsPost(newPost); + expect(NewsPost.lastNewsPost()._id).to.equal(previousPost._id); + }); + + it('does not update the post if new one is not published', () => { + const previousPost = new NewsPost({ + _id: v4(), publishDate: new Date(publishDate + 250), published: true, + }); + NewsPost.updateLastNewsPost(previousPost); + const newPost = new NewsPost({ + _id: v4(), publishDate: new Date(publishDate + 300), published: false, + }); + NewsPost.updateLastNewsPost(newPost); + expect(NewsPost.lastNewsPost()._id).to.equal(previousPost._id); + }); + }); + + context('refreshes NewsPost', () => { + let intervalId; + + beforeEach(async () => { + // Delete all existing posts from the database + await NewsPost.remove(); + }); + + afterEach(() => { + if (intervalId) clearInterval(intervalId); + }); + + it('refreshes the last post at a specific interval', async () => { + await sleep(0.1); // wait 100ms to make sure all previous posts are in the past + const previousPost = { + _id: v4(), publishDate: new Date(), published: true, + }; + NewsPost.updateLastNewsPost(previousPost); + intervalId = refreshNewsPost(50); // refreshes every 50ms + + await sleep(0.1); // wait 100ms to make sure the new post has a more recent publishDate + const newPost = await NewsPost.create({ + _id: v4(), + publishDate: new Date(), + author: v4(), + published: true, + title: 'Title', + credits: 'credits', + text: 'text', + }); + + expect(NewsPost.lastNewsPost()._id).to.equal(previousPost._id); + await sleep(0.15); // wait 150ms + + expect(NewsPost.lastNewsPost()._id).to.equal(newPost._id); + }); + }); +}); diff --git a/test/api/unit/models/user.test.js b/test/api/unit/models/user.test.js index f3345e5478..b3a5200ece 100644 --- a/test/api/unit/models/user.test.js +++ b/test/api/unit/models/user.test.js @@ -1,87 +1,90 @@ import moment from 'moment'; import { model as User } from '../../../../website/server/models/user'; +import { model as NewsPost } from '../../../../website/server/models/newsPost'; import { model as Group } from '../../../../website/server/models/group'; import common from '../../../../website/common'; describe('User Model', () => { - it('keeps user._tmp when calling .toJSON', () => { - const user = new User({ - auth: { - local: { - username: 'username', - lowerCaseUsername: 'username', - email: 'email@email.email', - salt: 'salt', - hashed_password: 'hashed_password', // eslint-disable-line camelcase + describe('.toJSON()', () => { + it('keeps user._tmp when calling .toJSON', () => { + const user = new User({ + auth: { + local: { + username: 'username', + lowerCaseUsername: 'username', + email: 'email@email.email', + salt: 'salt', + hashed_password: 'hashed_password', // eslint-disable-line camelcase + }, }, - }, + }); + + user._tmp = { ok: true }; + user._nonTmp = { ok: true }; + + expect(user._tmp).to.eql({ ok: true }); + expect(user._nonTmp).to.eql({ ok: true }); + + const toObject = user.toObject(); + const toJSON = user.toJSON(); + + expect(toObject).to.not.have.keys('_tmp'); + expect(toObject).to.not.have.keys('_nonTmp'); + + expect(toJSON).to.have.any.key('_tmp'); + expect(toJSON._tmp).to.eql({ ok: true }); + expect(toJSON).to.not.have.keys('_nonTmp'); }); - user._tmp = { ok: true }; - user._nonTmp = { ok: true }; + it('can add computed stats to a JSONified user object', () => { + const user = new User(); + const userToJSON = user.toJSON(); - expect(user._tmp).to.eql({ ok: true }); - expect(user._nonTmp).to.eql({ ok: true }); + expect(userToJSON.stats.maxMP).to.not.exist; + expect(userToJSON.stats.maxHealth).to.not.exist; + expect(userToJSON.stats.toNextLevel).to.not.exist; - const toObject = user.toObject(); - const toJSON = user.toJSON(); + User.addComputedStatsToJSONObj(userToJSON.stats, userToJSON); - expect(toObject).to.not.have.keys('_tmp'); - expect(toObject).to.not.have.keys('_nonTmp'); + expect(userToJSON.stats.maxMP).to.exist; + expect(userToJSON.stats.maxHealth).to.equal(common.maxHealth); + expect(userToJSON.stats.toNextLevel).to.equal(common.tnl(user.stats.lvl)); + }); - expect(toJSON).to.have.any.key('_tmp'); - expect(toJSON._tmp).to.eql({ ok: true }); - expect(toJSON).to.not.have.keys('_nonTmp'); - }); + it('can transform user object without mongoose helpers', async () => { + const user = new User(); + await user.save(); + const userToJSON = await User.findById(user._id).lean().exec(); - it('can add computed stats to a JSONified user object', () => { - const user = new User(); - const userToJSON = user.toJSON(); + expect(userToJSON.stats.maxMP).to.not.exist; + expect(userToJSON.stats.maxHealth).to.not.exist; + expect(userToJSON.stats.toNextLevel).to.not.exist; + expect(userToJSON.id).to.not.exist; - expect(userToJSON.stats.maxMP).to.not.exist; - expect(userToJSON.stats.maxHealth).to.not.exist; - expect(userToJSON.stats.toNextLevel).to.not.exist; + User.transformJSONUser(userToJSON); - User.addComputedStatsToJSONObj(userToJSON.stats, userToJSON); + expect(userToJSON.id).to.equal(userToJSON._id); + expect(userToJSON.stats.maxMP).to.not.exist; + expect(userToJSON.stats.maxHealth).to.not.exist; + expect(userToJSON.stats.toNextLevel).to.not.exist; + }); - expect(userToJSON.stats.maxMP).to.exist; - expect(userToJSON.stats.maxHealth).to.equal(common.maxHealth); - expect(userToJSON.stats.toNextLevel).to.equal(common.tnl(user.stats.lvl)); - }); + it('can transform user object without mongoose helpers (including computed stats)', async () => { + const user = new User(); + await user.save(); + const userToJSON = await User.findById(user._id).lean().exec(); - it('can transform user object without mongoose helpers', async () => { - const user = new User(); - await user.save(); - const userToJSON = await User.findById(user._id).lean().exec(); + expect(userToJSON.stats.maxMP).to.not.exist; + expect(userToJSON.stats.maxHealth).to.not.exist; + expect(userToJSON.stats.toNextLevel).to.not.exist; - expect(userToJSON.stats.maxMP).to.not.exist; - expect(userToJSON.stats.maxHealth).to.not.exist; - expect(userToJSON.stats.toNextLevel).to.not.exist; - expect(userToJSON.id).to.not.exist; + User.transformJSONUser(userToJSON, true); - User.transformJSONUser(userToJSON); - - expect(userToJSON.id).to.equal(userToJSON._id); - expect(userToJSON.stats.maxMP).to.not.exist; - expect(userToJSON.stats.maxHealth).to.not.exist; - expect(userToJSON.stats.toNextLevel).to.not.exist; - }); - - it('can transform user object without mongoose helpers (including computed stats)', async () => { - const user = new User(); - await user.save(); - const userToJSON = await User.findById(user._id).lean().exec(); - - expect(userToJSON.stats.maxMP).to.not.exist; - expect(userToJSON.stats.maxHealth).to.not.exist; - expect(userToJSON.stats.toNextLevel).to.not.exist; - - User.transformJSONUser(userToJSON, true); - - expect(userToJSON.id).to.equal(userToJSON._id); - expect(userToJSON.stats.maxMP).to.exist; - expect(userToJSON.stats.maxHealth).to.equal(common.maxHealth); - expect(userToJSON.stats.toNextLevel).to.equal(common.tnl(user.stats.lvl)); + expect(userToJSON.id).to.equal(userToJSON._id); + expect(userToJSON.stats.maxMP).to.exist; + expect(userToJSON.stats.maxHealth).to.equal(common.maxHealth); + expect(userToJSON.stats.toNextLevel).to.equal(common.tnl(user.stats.lvl)); + }); }); context('achievements', () => { @@ -827,4 +830,46 @@ describe('User Model', () => { expect(daysMissed).to.eql(0); }); }); + + it('isNewsPoster', async () => { + const user = new User(); + await user.save(); + + expect(user.isNewsPoster()).to.equal(false); + + user.contributor.newsPoster = true; + expect(user.isNewsPoster()).to.equal(true); + }); + + describe('checkNewStuff', () => { + let user; + + beforeEach(() => { + user = new User(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('no last news post', () => { + sandbox.stub(NewsPost, 'lastNewsPost').returns(null); + expect(user.checkNewStuff()).to.equal(false); + expect(user.toJSON().flags.newStuff).to.equal(false); + }); + + it('last news post read', () => { + sandbox.stub(NewsPost, 'lastNewsPost').returns({ _id: '123' }); + user.flags.lastNewStuffRead = '123'; + expect(user.checkNewStuff()).to.equal(false); + expect(user.toJSON().flags.newStuff).to.equal(false); + }); + + it('last news post not read', () => { + sandbox.stub(NewsPost, 'lastNewsPost').returns({ _id: '123' }); + user.flags.lastNewStuffRead = '124'; + expect(user.checkNewStuff()).to.equal(true); + expect(user.toJSON().flags.newStuff).to.equal(true); + }); + }); }); diff --git a/test/api/v3/integration/news/GET-news.test.js b/test/api/v3/integration/news/GET-news.test.js index 8de8afbc05..b47e433a6a 100644 --- a/test/api/v3/integration/news/GET-news.test.js +++ b/test/api/v3/integration/news/GET-news.test.js @@ -4,7 +4,6 @@ import { describe('GET /news', () => { let api; - beforeEach(async () => { api = requester(); }); diff --git a/test/api/v3/integration/news/POST-news_tell_me_later.test.js b/test/api/v3/integration/news/POST-news_tell_me_later.test.js index bd1cdfb147..c5c91f642c 100644 --- a/test/api/v3/integration/news/POST-news_tell_me_later.test.js +++ b/test/api/v3/integration/news/POST-news_tell_me_later.test.js @@ -1,24 +1,27 @@ import { generateUser, } from '../../../../helpers/api-integration/v3'; +import { model as NewsPost } from '../../../../../website/server/models/newsPost'; describe('POST /news/tell-me-later', () => { let user; beforeEach(async () => { - user = await generateUser({ - 'flags.newStuff': true, + NewsPost.updateLastNewsPost({ + _id: '1234', publishDate: new Date(), title: 'Title', published: true, }); + user = await generateUser(); }); it('marks new stuff as read and adds notification', async () => { - expect(user.flags.newStuff).to.equal(true); const initialNotifications = user.notifications.length; await user.post('/news/tell-me-later'); await user.sync(); - expect(user.flags.newStuff).to.equal(false); + expect(user.flags.lastNewStuffRead).to.equal('1234'); + // fetching the user because newStuff is a computed property + expect((await user.get('/user')).flags.newStuff).to.equal(false); expect(user.notifications.length).to.equal(initialNotifications + 1); const notification = user.notifications[user.notifications.length - 1]; diff --git a/test/api/v3/integration/user/PUT-user.test.js b/test/api/v3/integration/user/PUT-user.test.js index 71e8500263..9025afb326 100644 --- a/test/api/v3/integration/user/PUT-user.test.js +++ b/test/api/v3/integration/user/PUT-user.test.js @@ -3,6 +3,7 @@ import { generateUser, translate as t, } from '../../../../helpers/api-integration/v3'; +import { model as NewsPost } from '../../../../../website/server/models/newsPost'; describe('PUT /user', () => { let user; @@ -101,6 +102,24 @@ describe('PUT /user', () => { message: t('displaynameIssueNewline'), }); }); + + it('can set flags.newStuff to false', async () => { + NewsPost.updateLastNewsPost({ + _id: '1234', publishDate: new Date(), title: 'Title', published: true, + }); + + await user.update({ + 'flags.lastNewStuffRead': '123', + }); + + await user.put('/user', { + 'flags.newStuff': false, + }); + + await user.sync(); + + expect(user.flags.lastNewStuffRead).to.eql('1234'); + }); }); context('Top Level Protected Operations', () => { diff --git a/test/api/v4/news/DELETE-news.test.js b/test/api/v4/news/DELETE-news.test.js new file mode 100644 index 0000000000..211c0d706d --- /dev/null +++ b/test/api/v4/news/DELETE-news.test.js @@ -0,0 +1,49 @@ +import { v4 } from 'uuid'; +import { + generateUser, + translate as t, +} from '../../../helpers/api-integration/v4'; + +describe('DELETE /news/:newsID', () => { + let user; + const newsPost = { + title: 'New Post', + publishDate: new Date(), + published: true, + credits: 'credits', + text: 'news body', + }; + beforeEach(async () => { + user = await generateUser({ + 'contributor.newsPoster': true, + }); + }); + + it('disallows access to non-newsPosters', async () => { + const nonAdminUser = await generateUser({ 'contributor.newsPoster': false }); + await expect(nonAdminUser.del(`/news/${v4()}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: 'You don\'t have news poster access.', + }); + }); + + it('returns an error if the post does not exist', async () => { + await expect(user.del(`/news/${v4()}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('newsPostNotFound'), + }); + }); + + it('deletes news posts', async () => { + const existingPost = await user.post('/news', newsPost); + await user.del(`/news/${existingPost._id}`); + + const returnedPosts = await user.get('/news'); + const deletedPost = returnedPosts.find(returnedPost => returnedPost._id === existingPost._id); + + expect(returnedPosts).is.an('array'); + expect(deletedPost).to.not.exist; + }); +}); diff --git a/test/api/v4/news/GET-news.test.js b/test/api/v4/news/GET-news.test.js new file mode 100644 index 0000000000..64db6d8391 --- /dev/null +++ b/test/api/v4/news/GET-news.test.js @@ -0,0 +1,50 @@ +import { + requester, generateUser, +} from '../../../helpers/api-integration/v4'; + +describe('GET /news', () => { + let api; + const newsPost = { + title: 'New Post', + publishDate: new Date(), + published: true, + credits: 'credits', + text: 'news body', + }; + + before(async () => { + api = requester(); + const user = await generateUser({ + 'contributor.newsPoster': true, + }); + + await Promise.all([ + user.post('/news', newsPost), + user.post('/news', newsPost), + user.post('/news', newsPost), + user.post('/news', newsPost), + user.post('/news', newsPost), + user.post('/news', newsPost), + user.post('/news', newsPost), + user.post('/news', newsPost), + user.post('/news', newsPost), + user.post('/news', newsPost), + user.post('/news', newsPost), + user.post('/news', newsPost), + ]); + }); + + it('returns the latest news in json format, does not require authentication, 10 per page', async () => { + const res = await api.get('/news'); + expect(res.length).to.be.equal(10); + expect(res[0].title).to.be.not.empty; + expect(res[0].text).to.be.not.empty; + }); + + it('supports pagination', async () => { + const res = await api.get('/news?page=1'); + expect(res.length).to.be.equal(2); + expect(res[0].title).to.be.not.empty; + expect(res[0].text).to.be.not.empty; + }); +}); diff --git a/test/api/v4/news/GET-news_id.test.js b/test/api/v4/news/GET-news_id.test.js new file mode 100644 index 0000000000..a6b7ed0579 --- /dev/null +++ b/test/api/v4/news/GET-news_id.test.js @@ -0,0 +1,36 @@ +import { v4 } from 'uuid'; +import { + generateUser, + translate as t, +} from '../../../helpers/api-integration/v4'; + +describe('GET /news/:newsID', () => { + let user; + const newsPost = { + title: 'New Post', + publishDate: new Date(), + published: true, + credits: 'credits', + text: 'news body', + }; + beforeEach(async () => { + user = await generateUser({ + 'contributor.newsPoster': true, + }); + }); + + it('returns an error if the post does not exist', async () => { + await expect(user.get(`/news/${v4()}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('newsPostNotFound'), + }); + }); + + it('fetches an existing post', async () => { + const existingPost = await user.post('/news', newsPost); + const fetchedPost = await user.get(`/news/${existingPost._id}`); + + expect(fetchedPost._id).to.equal(existingPost._id); + }); +}); diff --git a/test/api/v4/news/POST-news.test.js b/test/api/v4/news/POST-news.test.js new file mode 100644 index 0000000000..f102616624 --- /dev/null +++ b/test/api/v4/news/POST-news.test.js @@ -0,0 +1,134 @@ +import moment from 'moment'; +import { + generateUser, + sleep, +} from '../../../helpers/api-integration/v4'; +import { model as NewsPost } from '../../../../website/server/models/newsPost'; + +describe('POST /news', () => { + let user; + const newsPost = { + title: 'New Post', + publishDate: new Date(), + published: true, + credits: 'credits', + text: 'news body', + }; + beforeEach(async () => { + user = await generateUser({ + 'contributor.newsPoster': true, + }); + }); + + it('disallows access to non-admins', async () => { + const nonAdminUser = await generateUser({ 'contributor.newsPoster': false }); + await expect(nonAdminUser.post('/news')).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: 'You don\'t have news poster access.', + }); + }); + + it('creates news posts', async () => { + const response = await user.post('/news', newsPost); + + expect(response.title).to.equal(newsPost.title); + expect(response.credits).to.equal(newsPost.credits); + expect(response.text).to.equal(newsPost.text); + expect(response._id).to.exist; + + const res = await user.get('/news'); + expect(res[0]._id).to.equal(response._id); + expect(res[0].title).to.equal(newsPost.title); + expect(res[0].text).to.equal(newsPost.text); + }); + + context('calls updateLastNewsPost', () => { + beforeEach(async () => { + await NewsPost.remove({ }); + }); + + afterEach(async () => { + newsPost.publishDate = new Date(); + newsPost.published = true; + }); + + it('new post is published and the most recent one', async () => { + newsPost.publishDate = new Date(); + const newPost = await user.post('/news', newsPost); + await sleep(0.05); + expect(NewsPost.lastNewsPost()._id).to.equal(newPost._id); + }); + + it('new post is not published', async () => { + newsPost.published = false; + const newPost = await user.post('/news', newsPost); + await sleep(0.05); + expect(NewsPost.lastNewsPost()._id).to.not.equal(newPost._id); + }); + + it('new post is published but in the future', async () => { + newsPost.publishDate = moment().add({ days: 1 }).toDate(); + const newPost = await user.post('/news', newsPost); + await sleep(0.05); + expect(NewsPost.lastNewsPost()._id).to.not.equal(newPost._id); + }); + + it('new post is published but not the most recent one', async () => { + const oldPost = await user.post('/news', newsPost); + newsPost.publishDate = moment().subtract({ days: 1 }).toDate(); + await user.post('/news', newsPost); + await sleep(0.05); + expect(NewsPost.lastNewsPost()._id).to.equal(oldPost._id); + }); + }); + + it('sets default fields', async () => { + const response = await user.post('/news', { + title: 'A post', + credits: 'Credits', + text: 'Text', + }); + + expect(response.published).to.equal(false); + expect(response.publishDate).to.exist; + expect(response.author).to.equal(user._id); + expect(response.createdAt).to.exist; + expect(response.updatedAt).to.exist; + }); + + context('required fields', () => { + it('title', async () => { + await expect(user.post('/news', { + text: 'Text', + credits: 'Credits', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'NewsPost validation failed', + }); + }); + + it('credits', async () => { + await expect(user.post('/news', { + text: 'Text', + title: 'Title', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'NewsPost validation failed', + }); + }); + + it('text', async () => { + await expect(user.post('/news', { + credits: 'credits', + title: 'Title', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'NewsPost validation failed', + }); + }); + }); +}); diff --git a/test/api/v4/news/POST-news_read.test.js b/test/api/v4/news/POST-news_read.test.js new file mode 100644 index 0000000000..f82c2a7fb1 --- /dev/null +++ b/test/api/v4/news/POST-news_read.test.js @@ -0,0 +1,22 @@ +import { + generateUser, +} from '../../../helpers/api-integration/v4'; +import { model as NewsPost } from '../../../../website/server/models/newsPost'; + +describe('POST /news/read', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('marks new stuff as read', async () => { + NewsPost.updateLastNewsPost({ _id: '1234', publishDate: new Date(), published: true }); + await user.post('/news/read'); + await user.sync(); + + expect(user.flags.lastNewStuffRead).to.equal('1234'); + // fetching the user because newStuff is a computed property + expect((await user.get('/user')).flags.newStuff).to.equal(false); + }); +}); diff --git a/test/api/v4/news/PUT-news_newsId.test.js b/test/api/v4/news/PUT-news_newsId.test.js new file mode 100644 index 0000000000..6301b94aa3 --- /dev/null +++ b/test/api/v4/news/PUT-news_newsId.test.js @@ -0,0 +1,103 @@ +import { v4 } from 'uuid'; +import { + generateUser, + translate as t, + sleep, +} from '../../../helpers/api-integration/v4'; +import { model as NewsPost } from '../../../../website/server/models/newsPost'; + +describe('PUT /news/:newsID', () => { + let user; + const newsPost = { + title: 'New Post', + publishDate: new Date(), + published: true, + credits: 'credits', + text: 'news body', + }; + beforeEach(async () => { + user = await generateUser({ + 'contributor.newsPoster': true, + }); + }); + + it('disallows access to non-admins', async () => { + const nonAdminUser = await generateUser({ 'contributor.newsPoster': false }); + await expect(nonAdminUser.put('/news/1234')).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: 'You don\'t have news poster access.', + }); + }); + + it('returns an error if the post does not exist', async () => { + await expect(user.put(`/news/${v4()}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('newsPostNotFound'), + }); + }); + + it('updates existing news posts', async () => { + const existingPost = await user.post('/news', newsPost); + const updatedPost = await user.put(`/news/${existingPost._id}`, { + title: 'Changed Title', + }); + + expect(updatedPost.title).to.equal('Changed Title'); + expect(updatedPost.credits).to.equal(existingPost.credits); + expect(updatedPost.text).to.equal(existingPost.text); + expect(updatedPost.published).to.equal(existingPost.published); + expect(updatedPost._id).to.equal(existingPost._id); + }); + + context('calls updateLastNewsPost', () => { + beforeEach(async () => { + await NewsPost.remove({ }); + }); + + it('updates post data', async () => { + const existingPost = await user.post('/news', { ...newsPost, publishDate: new Date() }); + const updatedPost = await user.put(`/news/${existingPost._id}`, { + title: 'Changed Title', + }); + await sleep(0.05); + + expect(NewsPost.lastNewsPost().title).to.equal(updatedPost.title); + }); + + it('updated post is not published', async () => { + const oldPost = await user.post('/news', { ...newsPost, publishDate: new Date() }); + const newUnpublished = await user.post('/news', { ...newsPost, published: false }); + await user.put(`/news/${newUnpublished._id}`, { + title: 'Changed Title', + }); + await sleep(0.05); + + expect(NewsPost.lastNewsPost()._id).to.equal(oldPost._id); + }); + + it('updated post is published', async () => { + await user.post('/news', { ...newsPost, publishDate: new Date() }); + const newUnpublished = await user.post('/news', { ...newsPost, published: false, publishDate: new Date() }); + await user.put(`/news/${newUnpublished._id}`, { + publishDate: new Date(), + published: true, + }); + await sleep(0.05); + + expect(NewsPost.lastNewsPost()._id).to.equal(newUnpublished._id); + }); + + it('updated post publishDate is in future', async () => { + const oldPost = await user.post('/news', { ...newsPost, publishDate: new Date() }); + const newUnpublished = await user.post('/news', newsPost); + await user.put(`/news/${newUnpublished._id}`, { + publishDate: Date.now() + 50000, + }); + await sleep(0.05); + + expect(NewsPost.lastNewsPost()._id).to.equal(oldPost._id); + }); + }); +}); diff --git a/website/client/src/assets/scss/modal.scss b/website/client/src/assets/scss/modal.scss index baec4afd79..15fae725c4 100644 --- a/website/client/src/assets/scss/modal.scss +++ b/website/client/src/assets/scss/modal.scss @@ -5,6 +5,10 @@ padding-left: 0px !important; } +.modal-content { + border-radius: 8px; +} + .modal-dialog { margin: 3rem auto 3rem; width: auto; diff --git a/website/client/src/components/achievements/newStuff.vue b/website/client/src/components/achievements/newStuff.vue deleted file mode 100644 index 53c427a383..0000000000 --- a/website/client/src/components/achievements/newStuff.vue +++ /dev/null @@ -1,81 +0,0 @@ - - - - - diff --git a/website/client/src/components/news/modal.vue b/website/client/src/components/news/modal.vue new file mode 100644 index 0000000000..072e941587 --- /dev/null +++ b/website/client/src/components/news/modal.vue @@ -0,0 +1,58 @@ + + + diff --git a/website/client/src/components/news/newsContent.vue b/website/client/src/components/news/newsContent.vue new file mode 100644 index 0000000000..9dfa240463 --- /dev/null +++ b/website/client/src/components/news/newsContent.vue @@ -0,0 +1,107 @@ + + + + + + + diff --git a/website/client/src/components/notifications.vue b/website/client/src/components/notifications.vue index aed0156cfa..206ebabd3d 100644 --- a/website/client/src/components/notifications.vue +++ b/website/client/src/components/notifications.vue @@ -118,7 +118,7 @@ import notifications from '@/mixins/notifications'; import guide from '@/mixins/guide'; import yesterdailyModal from './tasks/yesterdailyModal'; -import newStuff from './achievements/newStuff'; +import newStuff from './news/modal'; import death from './achievements/death'; import lowHealth from './achievements/lowHealth'; import levelUp from './achievements/levelUp'; diff --git a/website/client/src/components/static/newStuff.vue b/website/client/src/components/static/newStuff.vue index 6a0c6d023d..772cbfa0ff 100644 --- a/website/client/src/components/static/newStuff.vue +++ b/website/client/src/components/static/newStuff.vue @@ -1,26 +1,18 @@ - - diff --git a/website/client/src/store/actions/index.js b/website/client/src/store/actions/index.js index 539f71c1e2..947be42878 100644 --- a/website/client/src/store/actions/index.js +++ b/website/client/src/store/actions/index.js @@ -16,6 +16,7 @@ import * as hall from './hall'; import * as shops from './shops'; import * as snackbars from './snackbars'; import * as worldState from './worldState'; +import * as news from './news'; // Actions should be named as 'actionName' and can be accessed as 'namespace:actionName' // Example: fetch in user.js -> 'user:fetch' @@ -37,6 +38,7 @@ const actions = flattenAndNamespace({ shops, snackbars, worldState, + news, }); export default actions; diff --git a/website/client/src/store/actions/news.js b/website/client/src/store/actions/news.js new file mode 100644 index 0000000000..594fca78b9 --- /dev/null +++ b/website/client/src/store/actions/news.js @@ -0,0 +1,16 @@ +import axios from 'axios'; + +export async function markAsRead (store) { + store.state.user.data.flags.newStuff = false; + return axios.post('/api/v4/news/read'); +} + +export function remindMeLater (store) { + store.state.user.data.flags.newStuff = false; + return axios.post('/api/v4/news/tell-me-later'); +} + +export async function fetch () { + const response = await axios.get('/api/v4/news'); + return response.data.data; +} diff --git a/website/client/src/store/actions/user.js b/website/client/src/store/actions/user.js index fa1de5dcc7..0d4327366b 100644 --- a/website/client/src/store/actions/user.js +++ b/website/client/src/store/actions/user.js @@ -131,11 +131,6 @@ export async function openMysteryItem (store) { return axios.post('/api/v4/user/open-mystery-item'); } -export function newStuffLater (store) { - store.state.user.data.flags.newStuff = false; - return axios.post('/api/v4/news/tell-me-later'); -} - export async function rebirth () { const result = await axios.post('/api/v4/user/rebirth'); diff --git a/website/common/locales/en/messages.json b/website/common/locales/en/messages.json index 2006b65dda..b195e812aa 100644 --- a/website/common/locales/en/messages.json +++ b/website/common/locales/en/messages.json @@ -53,5 +53,6 @@ "messageDeletedUser": "Sorry, this user has deleted their account.", "messageMissingDisplayName": "Missing display name.", "reportedMessage": "You have reported this message to moderators.", - "canDeleteNow": "You can now delete the message if you wish." + "canDeleteNow": "You can now delete the message if you wish.", + "newsPostNotFound": "News Post not found or you don't have access." } diff --git a/website/common/script/errors/apiErrorMessages.js b/website/common/script/errors/apiErrorMessages.js index e9b3ca0be0..2d45de4e3f 100644 --- a/website/common/script/errors/apiErrorMessages.js +++ b/website/common/script/errors/apiErrorMessages.js @@ -27,6 +27,9 @@ export default { missingSubKey: 'Missing "req.query.sub"', invalidGemsBlock: 'The supplied gemsBlock does not exists', + postIdRequired: '"postId" must be a valid UUID.', + noNewsPosterAccess: 'You don\'t have news poster access.', + ipAddressBlocked: 'Your access to Habitica has been blocked. This may be due to a breach of our Terms of Service or for other reasons. For details or to ask to be unblocked, please email admin@habitica.com or ask your parent or guardian to email them. Include your Habitica @Username or User Id in the email if you know it.', clientRateLimited: 'This User ID or IP address has been rate limited due to an excess amount of requests to the Habitica API v3. More info can be found in the response headers and at https://habitica.fandom.com/wiki/Guidance_for_Comrades#Rules_for_Third-Party_Tools under the section Rate Limiting.', diff --git a/website/server/controllers/api-v3/news.js b/website/server/controllers/api-v3/news.js index 28c6727d9e..18a153550d 100644 --- a/website/server/controllers/api-v3/news.js +++ b/website/server/controllers/api-v3/news.js @@ -1,10 +1,9 @@ +import md from 'habitica-markdown'; import { authWithHeaders } from '../../middlewares/auth'; +import { model as NewsPost } from '../../models/newsPost'; const api = {}; -// @TODO export this const, cannot export it from here because only routes are exported from -// controllers -const LAST_ANNOUNCEMENT_TITLE = 'NEW BACKGROUNDS AND ARMOIRE ITEMS! PLUS, SPOOKY SPARKLES IN THE SEASONAL SHOP!'; const worldDmg = { // @TODO bailey: false, }; @@ -24,51 +23,42 @@ api.getNews = { async handler (req, res) { const baileyClass = worldDmg.bailey ? 'npc_bailey_broken' : 'npc_bailey'; - res.status(200).send({ - html: ` -
-
-
-
-

${res.t('newStuff')}

-

10/1/2020 - ${LAST_ANNOUNCEMENT_TITLE}

+ const lastNewsPost = NewsPost.lastNewsPost(); + if (lastNewsPost) { + res.status(200).send({ + html: ` +
+
+
+
+

${res.t('newStuff')}

+

${lastNewsPost.title.toUpperCase()}

+
+
+
+

+ ${md.unsafeHTMLRender(lastNewsPost.text)} +

+
+ by ${lastNewsPost.credits}
-
-
-

October Backgrounds and Armoire Items!

-

- We’ve added three new backgrounds to the Background Shop! Now your avatar can dare to - visit a Haunted Forest, brave the Spooky Scarecrow Field, or bask in the glow of the - Crescent Moon. Check them out under User Icon > Backgrounds on web and Menu > Inventory > - Customize Avatar on mobile! -

-

- Plus, there’s new Gold-purchasable equipment in the Enchanted Armoire, including the - Autumn Enchanter Set. Better work hard on your real-life tasks to earn all the pieces! - Enjoy :) -

-
by AnnDeLune and SabreCat
-
-

Spooky Sparkles in Seasonal Shop

-

- There's a new Gold-purchasable item in the Seasonal Shop: - Spooky Sparkles! Buy some and then cast it on your friends. I wonder what it will do? -

-

- If you have Spooky Sparkles cast on you, you will receive the "Alarming Friends" badge! - Don't worry, any mysterious effects will wear off the next day.... or you can cancel them - early by buying an Opaque Potion! -

-

- While you're at it, be sure to check out all the other items in the Seasonal Shop! There - are lots of equipment items from the previous Fall Festivals. The Seasonal Shop will only - be open until October 31st, so stock up now. -

-
by Lemoness and SabreCat
-
- `, - }); + `, + }); + } else { + res.status(200).send({ + html: ` +
+
+
+
+

${res.t('newStuff')}

+
+
+
+ `, + }); + } }, }; @@ -79,7 +69,6 @@ api.getNews = { * Prevent this specific Bailey message from appearing automatically. * @apiGroup News * - * * @apiSuccess {Object} data An empty Object * */ @@ -90,13 +79,17 @@ api.tellMeLaterNews = { async handler (req, res) { const { user } = res.locals; - user.flags.newStuff = false; + const lastNewsPost = NewsPost.lastNewsPost(); + if (lastNewsPost) { + user.flags.lastNewStuffRead = lastNewsPost._id; - const existingNotificationIndex = user.notifications.findIndex(n => n && n.type === 'NEW_STUFF'); - if (existingNotificationIndex !== -1) user.notifications.splice(existingNotificationIndex, 1); - user.addNotification('NEW_STUFF', { title: LAST_ANNOUNCEMENT_TITLE }, true); // seen by default + const existingNotificationIndex = user.notifications.findIndex(n => n && n.type === 'NEW_STUFF'); + if (existingNotificationIndex !== -1) user.notifications.splice(existingNotificationIndex, 1); + user.addNotification('NEW_STUFF', { title: lastNewsPost.title.toUpperCase() }, true); // seen by default + + await user.save(); + } - await user.save(); res.respond(200, {}); }, }; diff --git a/website/server/controllers/api-v4/news.js b/website/server/controllers/api-v4/news.js new file mode 100644 index 0000000000..49a3ef5680 --- /dev/null +++ b/website/server/controllers/api-v4/news.js @@ -0,0 +1,224 @@ +import _ from 'lodash'; +import { authWithHeaders } from '../../middlewares/auth'; +import apiError from '../../libs/apiError'; +import { model as NewsPost } from '../../models/newsPost'; +import { ensureNewsPoster } from '../../middlewares/ensureAccessRight'; +import { + NotFound, +} from '../../libs/errors'; + +const api = {}; + +/** + * @apiDefine postIdRequired + * @apiError (400) {BadRequest} postIdRequired A postId is required + */ + +/** + * @apiDefine NewsPostNotFound + * @apiError (404) {NotFound} NewsPostNotFound The specified news post could not be found. + */ + +/** + * @api {get} /api/v4/news Get latest Bailey announcements + * @apiName GetNews + * @apiGroup News + * + * @apiParam (Query) {Number} [page] This parameter can be used to specify the page number + * (the initial page is number 0 and not required). + * + * @apiSuccess {Array} Data An array of Bailey posts + * + */ +api.getNews = { + method: 'GET', + url: '/news', + middlewares: [authWithHeaders({ + optional: true, + })], + noLanguage: true, + async handler (req, res) { + const { user } = res.locals; + const { page } = req.query; + + let isNewsPoster = false; + if (user) { + isNewsPoster = user.isNewsPoster(); + } + + const results = await NewsPost.getNews(isNewsPoster, { page }); + res.respond(200, results); + }, +}; + +/** + * @api {post} /api/v4/news Create a new news post + * @apiName CreateNewsPost + * @apiGroup News + * + * @apiSuccess {Object} data The created news post (See /website/server/models/newsPost.js) + * + * @apiSuccessExample {json} Post: + * HTTP/1.1 200 OK + * { + * "title": "News Title", + * ... + * } + * + * @apiPermission NewsPoster + */ +api.createNews = { + method: 'POST', + url: '/news', + middlewares: [authWithHeaders(), ensureNewsPoster], + async handler (req, res) { + const newsPost = new NewsPost(NewsPost.sanitize(req.body)); + newsPost.author = res.locals.user._id; + await newsPost.save(); + + res.respond(201, newsPost); + + NewsPost.updateLastNewsPost(newsPost); + }, +}; + +/** + * @api {get} /api/v4/news/:postId Get a specific news post + * @apiName GetNewsPost + * @apiGroup News + * + * @apiParam (Path) {String} postId The posts _id + * + * @apiSuccess {Object} data The news post (See /website/server/models/newsPost.js) + * + * @apiSuccessExample {json} Post: + * HTTP/1.1 200 OK + * { + * "title": "News Title", + * ... + * } + * + * @apiUse postIdRequired + * @apiUse NewsPostNotFound + * + */ +api.getPost = { + method: 'GET', + url: '/news/:postId', + middlewares: [authWithHeaders({ + optional: true, + })], + noLanguage: true, + async handler (req, res) { + req.checkParams('postId', apiError('postIdRequired')).notEmpty().isUUID(); + const { user } = res.locals; + + const newsPost = await NewsPost.findById(req.params.postId).exec(); + if (!newsPost || (!user.isNewsPoster() && !newsPost.isPublished)) { + throw new NotFound(res.t('newsPostNotFound')); + } else { + res.respond(200, newsPost); + } + }, +}; + +/** + * @api {put} /api/v4/news/:postId Update a news post + * @apiName UpdateNewsPost + * @apiGroup News + * + * @apiParam (Path) {String} postId The posts _id + * + * @apiSuccess {Object} data The updated news post (See /website/server/models/newsPost.js) + * + * @apiSuccessExample {json} Post: + * HTTP/1.1 200 OK + * { + * "title": "News Title", + * ... + * } + * + * @apiUse postIdRequired + * @apiUse NewsPostNotFound + * + * @apiPermission NewsPoster + */ +api.updateNews = { + method: 'PUT', + url: '/news/:postId', + middlewares: [authWithHeaders(), ensureNewsPoster], + async handler (req, res) { + req.checkParams('postId', apiError('postIdRequired')).notEmpty().isUUID(); + const validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + const newsPost = await NewsPost.findById(req.params.postId).exec(); + if (!newsPost) throw new NotFound(res.t('newsPostNotFound')); + + _.merge(newsPost, NewsPost.sanitize(req.body)); + const savedPost = await newsPost.save(); + + res.respond(200, savedPost); + + NewsPost.updateLastNewsPost(newsPost); + }, +}; + +/** + * @api {delete} /api/v4/news/:postId Delete a news post + * @apiName DeleteNewsPost + * @apiGroup News + * + * @apiParam (Path) {String} postId The posts _id + * + * @apiSuccess {Object} data An empty object + * + * @apiUse postIdRequired + * @apiUse NewsPostNotFound + * + * @apiPermission NewsPoster + */ +api.deleteNews = { + method: 'DELETE', + url: '/news/:postId', + middlewares: [authWithHeaders(), ensureNewsPoster], + async handler (req, res) { + req.checkParams('postId', apiError('postIdRequired')).notEmpty().isUUID(); + const validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + const newsPost = await NewsPost.findById(req.params.postId).exec(); + if (!newsPost) throw new NotFound(res.t('newsPostNotFound')); + + await NewsPost.remove({ _id: req.params.postId }).exec(); + + res.respond(200, {}); + }, +}; + +/** + * @api {post} /api/v4/news/read Mark the latest Bailey announcement as read + * @apiName MarkNewsRead + * @apiGroup News + * + * @apiSuccess {Object} data An empty Object + */ +api.markNewsRead = { + method: 'POST', + middlewares: [authWithHeaders()], + url: '/news/read', + async handler (req, res) { + const { user } = res.locals; + + const lastNewsPost = NewsPost.lastNewsPost(); + if (lastNewsPost) { + user.flags.lastNewStuffRead = lastNewsPost._id; + + await user.save(); + } + + res.respond(200, {}); + }, +}; + +export default api; diff --git a/website/server/libs/user/index.js b/website/server/libs/user/index.js index 26790993ec..7ab2e44ef3 100644 --- a/website/server/libs/user/index.js +++ b/website/server/libs/user/index.js @@ -6,6 +6,7 @@ import { NotAuthorized, } from '../errors'; import { model as User, schema as UserSchema } from '../../models/user'; +import { model as NewsPost } from '../../models/newsPost'; import { nameContainsSlur, nameContainsNewline } from './validation'; export async function get (req, res, { isV3 = false }) { @@ -42,7 +43,6 @@ const updatablePaths = [ 'flags.welcomed', 'flags.cardReceived', 'flags.warnedLowHealth', - 'flags.newStuff', 'achievements', @@ -55,7 +55,6 @@ const updatablePaths = [ 'profile', 'stats', 'inbox.optOut', - 'tags', ]; // This tells us for which paths users can call `PUT /user`. @@ -122,9 +121,7 @@ export async function update (req, res, { isV3 = false }) { throw new NotAuthorized(res.t('mustPurchaseToSet', { val, key })); } - if (acceptablePUTPaths[key] && key !== 'tags') { - _.set(user, key, val); - } else if (key === 'tags') { + if (key === 'tags') { if (!Array.isArray(val)) throw new BadRequest('mustBeArray'); const removedTagsIds = []; @@ -161,6 +158,15 @@ export async function update (req, res, { isV3 = false }) { tags: tagId, }, }, { multi: true }).exec()); + } else if (key === 'flags.newStuff' && val === false) { + // flags.newStuff was removed from the user schema and is only returned for compatibility + // reasons but we're keeping the ability to set it in API v3 + const lastNewsPost = NewsPost.lastNewsPost(); + if (lastNewsPost) { + user.flags.lastNewStuffRead = lastNewsPost._id; + } + } else if (acceptablePUTPaths[key]) { + _.set(user, key, val); } else { throw new NotAuthorized(res.t('messageUserOperationProtected', { operation: key })); } diff --git a/website/server/middlewares/appRoutes.js b/website/server/middlewares/appRoutes.js index 646985c3a5..19931eab6b 100644 --- a/website/server/middlewares/appRoutes.js +++ b/website/server/middlewares/appRoutes.js @@ -36,6 +36,7 @@ app.use('/api/v3', rateLimiter, v3Router); const v4RouterOverrides = [ // 'GET-/status', Example to override the GET /status api call 'POST-/user/auth/local/register', + 'GET-/news', 'GET-/user', 'PUT-/user', 'POST-/user/class/cast/:spellId', diff --git a/website/server/middlewares/ensureAccessRight.js b/website/server/middlewares/ensureAccessRight.js index a16da0d5f7..12b2293a68 100644 --- a/website/server/middlewares/ensureAccessRight.js +++ b/website/server/middlewares/ensureAccessRight.js @@ -13,6 +13,16 @@ export function ensureAdmin (req, res, next) { return next(); } +export function ensureNewsPoster (req, res, next) { + const { user } = res.locals; + + if (!user.contributor.newsPoster) { + return next(new NotAuthorized(apiError('noNewsPosterAccess'))); + } + + return next(); +} + export function ensureSudo (req, res, next) { const { user } = res.locals; diff --git a/website/server/models/newsPost.js b/website/server/models/newsPost.js new file mode 100644 index 0000000000..3f1ccb236a --- /dev/null +++ b/website/server/models/newsPost.js @@ -0,0 +1,95 @@ +import mongoose from 'mongoose'; +import baseModel from '../libs/baseModel'; +import logger from '../libs/logger'; + +const { Schema } = mongoose; +const POSTS_PER_PAGE = 10; + +export const schema = new Schema({ + title: { $type: String, required: true }, + text: { $type: String, required: true }, + credits: { $type: String, required: true }, + author: { $type: String, ref: 'User', required: true }, + publishDate: { $type: Date, required: true, default: Date.now }, + published: { $type: Boolean, required: true, default: false }, +}, { + strict: true, + minimize: false, // So empty objects are returned + typeKey: '$type', // So that we can use fields named `type` +}); + +schema.plugin(baseModel, { + noSet: ['_id', 'author'], + timestamps: true, +}); + +schema.statics.getNews = async function getNews (isAdmin, options = { page: 0 }) { + let query; + if (!isAdmin) { + query = this.find({ + published: true, + publishDate: { $lte: new Date() }, + }); + } else { + query = this.find(); + } + + let page = 0; + if (typeof options.page !== 'undefined') { + page = options.page; + } + + return query + .sort({ publishDate: -1 }) + .limit(POSTS_PER_PAGE) + .skip(POSTS_PER_PAGE * Number(page)) + .exec(); +}; + +const NEWS_CACHE_TIME = 5 * 60 * 1000; + +let cachedLastNewsPost = null; + +schema.statics.getLastPostFromDatabase = async function getLastPostFromDatabase () { + const post = await this.findOne({ + published: true, + publishDate: { $lte: new Date() }, + }).sort({ publishDate: -1 }).exec(); + + return post; +}; + +schema.statics.lastNewsPost = function lastNewsPost () { + return cachedLastNewsPost; +}; + +schema.statics.updateLastNewsPost = function updateLastNewsPost (newPost) { + const isSame = !cachedLastNewsPost ? false : cachedLastNewsPost._id === newPost._id; + const isPublished = newPost.published; + const isNewer = !cachedLastNewsPost ? true : cachedLastNewsPost.publishDate < newPost.publishDate; + const isInFuture = newPost.publishDate > (new Date()); + if ( + isSame // if the same post it could have been updated + || (isPublished && isNewer && !isInFuture) + ) { + cachedLastNewsPost = newPost; + } +}; + +export const model = mongoose.model('NewsPost', schema); + +function getAndUpdateLastNewsPost () { + model.getLastPostFromDatabase().then(lastPost => { + if (lastPost) { + model.updateLastNewsPost(lastPost); + } + }).catch(err => logger.error(err)); +} + +export function refreshNewsPost (interval) { + return setInterval(() => getAndUpdateLastNewsPost(), interval); +} + +// Fetches the last news post and refresh it every 5 minutes +getAndUpdateLastNewsPost(); +refreshNewsPost(NEWS_CACHE_TIME); diff --git a/website/server/models/user/hooks.js b/website/server/models/user/hooks.js index 8ee9e8d2e8..3bcbab791b 100644 --- a/website/server/models/user/hooks.js +++ b/website/server/models/user/hooks.js @@ -31,6 +31,10 @@ schema.plugin(baseModel, { delete plainObj.filters; + if (plainObj.flags && originalDoc.isSelected('flags.lastNewStuffRead')) { + plainObj.flags.newStuff = originalDoc.checkNewStuff(); + } + return plainObj; }, }); diff --git a/website/server/models/user/methods.js b/website/server/models/user/methods.js index 9c7d0d9a7c..d92fd30557 100644 --- a/website/server/models/user/methods.js +++ b/website/server/models/user/methods.js @@ -22,6 +22,7 @@ import * as inboxLib from '../../libs/inbox'; // eslint-disable-line import/no-c import amazonPayments from '../../libs/payments/amazon'; // eslint-disable-line import/no-cycle import stripePayments from '../../libs/payments/stripe'; // eslint-disable-line import/no-cycle import paypalPayments from '../../libs/payments/paypal'; // eslint-disable-line import/no-cycle +import { model as NewsPost } from '../newsPost'; const { daysSince } = common; @@ -295,6 +296,12 @@ schema.statics.transformJSONUser = function transformJSONUser (jsonUser, addComp if (addComputedStats) this.addComputedStatsToJSONObj(jsonUser.stats, jsonUser); }; +// Returns true if the user has read the last news post +schema.methods.checkNewStuff = function checkNewStuff () { + const lastNewsPost = NewsPost.lastNewsPost(); + return Boolean(lastNewsPost && this.flags && this.flags.lastNewStuffRead !== lastNewsPost._id); +}; + // Add stats.toNextLevel, stats.maxMP and stats.maxHealth // to a JSONified User stats object schema.statics.addComputedStatsToJSONObj = function addComputedStatsToUserJSONObj ( @@ -491,7 +498,11 @@ schema.methods.isMemberOfGroupPlan = async function isMemberOfGroupPlan () { }; schema.methods.isAdmin = function isAdmin () { - return this.contributor && this.contributor.admin; + return Boolean(this.contributor && this.contributor.admin); +}; + +schema.methods.isNewsPoster = function isNewsPoster () { + return Boolean(this.contributor && this.contributor.newsPoster); }; // When converting to json add inbox messages from the Inbox collection diff --git a/website/server/models/user/schema.js b/website/server/models/user/schema.js index abc6426a56..dc62630f59 100644 --- a/website/server/models/user/schema.js +++ b/website/server/models/user/schema.js @@ -158,6 +158,7 @@ export default new Schema({ max: 9, }, admin: Boolean, + newsPoster: Boolean, sudo: Boolean, // Artisan, Friend, Blacksmith, etc text: String, @@ -245,7 +246,11 @@ export default new Schema({ }, dropsEnabled: { $type: Boolean, default: false }, // unused itemsEnabled: { $type: Boolean, default: false }, - newStuff: { $type: Boolean, default: false }, + lastNewStuffRead: { $type: String, default: '' }, + // The newStuff field was changed to be a computed property when returning the user in json, + // so that it doesn't have to be updated for each bailey post. + // See models/user/hooks#toJSONTransform + // newStuff: { $type: Boolean, default: false }, rewrite: { $type: Boolean, default: true }, classSelected: { $type: Boolean, default: false }, mathUpdates: Boolean,