Implement Bailey CMS API (#10739)

* Begin refactoring news API to return individual markdown posts

* Implement simple bailey CMS

* Prevented users with lvl less than 10 from seeing mana

* Added in class checks and notification tests

* Added getter use

* Fixed class check

* chore(i18n): update locales

* 4.60.2

* remove tests that are no longer needed because we won't be purging private messages (#10670)

Ref: this comment from paglias: https://github.com/HabitRPG/habitica/issues/7940#issuecomment-406489506

* remove .only

* allow challenge leader/owner to view/join/modify challenge in private group they've left - fixes #9753 (#10606)

* rename hasAccess to canJoin for challenges

This is so the function won't be used accidentally for other
purposes, since hasAccess could be misinterpretted.

* add isLeader function for challenges

* allow challenge leader to join/modify/end challenge when they're not in the private group it's in

* delete duplicate test

* clarify title of existing tests

* add tests and adjust existing tests to reduce privileges of test users

* fix lint errors

* remove pointless isLeader check (it's checked in canJoin)

* Correct Challenges tooltip in Guild view (#10667)

* Fix new party member cannot join pending quest (#10648)

* Saved sort selection into local storage for later use - fixes #10432 (#10655)

* Saved sort selection into local storage for later use

* Updated code to use userLocalManager module

* Fix initial position item info when selecting one item after another (fixes #10077) (#10661)

* Update lastMouseMoveEvent even when dragging an egg or potion.

* Update lastMouseMoveEvent even when dragging a food item.

* Refactor/market vue (#10601)

* extract inventoryDrawer from market

* show scrollbar only if needed

* extract featuredItemsHeader / pinUtils

* extract pageLayout

* extract layoutSection / filterDropdown - fix sortByNumber

* rollback sortByNumber order-fix

* move equipment lists out of the layout-section (for now)

* refactor sellModal

* extract checkbox

* extract equipment section

* extract category row

* revert scroll - remove sellModal item template

* fix(lint): commas and semis

* Created category item component (#10613)

* extract filter sidebar

* fix gemCount - fix raising the item count if the item wasn't previously owned

* fixes #10659

* remove unneeded method

* fix typo when importing component

* feat(content): Forest Friends Quest Bundle

* chore(sprites): compile

* chore(i18n): update locales

* 4.60.3

* fix(bcrypt): install fork compatible with Node 8

* chore(i18n): update locales

* 4.60.4

* add swear words - TRIGGER / CONTENT WARNING: assault, slurs, swearwords, etc

* add pinUtils-mixin   - fixes #10682 (#10683)

* chore(news): Bailey

* chore(i18n): update locales

* 4.60.5

* Improve rendering banner about sleeping in the inn

See #10695

* Display settings in one column

* Small Updates (#10701)

* small updates

* fix client unit test

* fix uuid validation

* Revert "Small Updates (#10701)" (#10702)

This reverts commit dd7fa73961.

* feat(event): Fall Festival 2018

* chore(sprites): compile

* chore(i18n): update locales

* 4.61.0

* Move inbox to its own model (#10428)

* shared model for chat and inbox

* disable inbox schema

* inbox: use separate model

* remove old code that used group.chat

* add back chat field (not used) and remove old tests

* remove inbox exclusions when loading user

* add GET /api/v3/inbox/messages

* add comment

* implement DELETE /inbox/messages/:messageid in v4

* implement GET /inbox/messages in v4 and update tests

* implement DELETE /api/v4/inbox/clear

* fix url

* fix doc

* update /export/inbox.html

* update other data exports

* add back messages in user schema

* add user.toJSONWithInbox

* add compativility until migration is done

* more compatibility

* fix tojson called twice

* add compatibility methods

* fix common tests

* fix v4 integration tests

* v3 get user -> with inbox

* start to fix tests

* fix v3 integration tests

* wip

* wip, client use new route

* update tests for members/send-private-message

* tests for get user in v4

* add tests for DELETE /inbox/messages/:messageId

* add tests for DELETE /inbox/clear in v4

* update docs

* fix tests

* initial migration

* fix migration

* fix migration

* migration fixes

* migrate api.enterCouponCode

* migrate api.castSpell

* migrate reset, reroll, rebirth

* add routes to v4 version

* fix tests

* fixes

* api.updateUser

* remove .only

* get user -> userLib

* refactor inbox.vue to work with new data model

* fix return message when messaging yourself

* wip fix bug with new conversation

* wip

* fix remaining ui issues

* move api.registerLocal, fixes

* keep only v3 version of GET /inbox/messages

* Fix API early Stat Point allocation (#10680)

* Refactor hasClass check to common so it can be used in shared & server-side code

* Check that user has selected class before allocating stat points

* chore(event): end Ember Hatching Potions

* chore(analytics): reenable navigation tracking

* update bcrypt

* Point achievement modal links to main site (#10709)

* Animal ears after death (#10691)

* Animal Ears purchasable with Gold if lost in Death

* remove ears from pinned items when set is bought

* standardise css and error handling for gems and coins

* revert accidental new line

* fix client tests

* Reduce margin-bottom of checklist-item from 10px to -3px. (#10684)

* chore(i18n): update locales

* 4.61.1

* Position inn banner when window is resized

* feat(content): Subscriber Items and Magic Potions

* chore(sprites): compile

* chore(i18n): update locales

* 4.62.0

* Update inn banner handling

* Fix banner offset on initial load

* Fix minor issues.

* Issue: 10660 - Fixed. Changed default to Please Enter A Value (#10718)

* Issue: 10660 - Fixed. Changed default to Please Enter A Value

* Issue: 10660 - Fixed/revision 2 Changed default to Enter A Value

* chore(news): Bailey announcements

* chore(i18n): update locales

* 4.62.1

* adjust wiki link for usernameInfo string

https://github.com/HabitRPG/habitica-private/issues/7#issuecomment-425405425

* raise coverage for tasks api calls (#10029)

* - updates a group task - approval is required
- updates a group task with checklist

* add expect to test the new checklist length

* - moves tasks to a specified position out of length

* remove unused line

* website getter tasks tests

* re-add sanitizeUserChallengeTask

* change config.json.example variable to be a string not a boolean

* fix tests - pick the text / up/down props too

* fix test - remove changes on text/up/down - revert sanitize condition - revert sanitization props

* chore(i18n): update locales

* 4.62.2

* chore(news): Bailey

* chore(i18n): update locales

* 4.62.3

* inbox: fix avatar display and order

* Username announcement (#10729)

* Change update username API call

The call no longer requires a password and also validates the username.

* Implement API call to verify username without setting it

* Improve coding style

* Apply username verification to registration

* Update error messages

* Validate display names.

* Fix API early Stat Point allocation (#10680)

* Refactor hasClass check to common so it can be used in shared & server-side code

* Check that user has selected class before allocating stat points

* chore(event): end Ember Hatching Potions

* chore(analytics): reenable navigation tracking

* update bcrypt

* Point achievement modal links to main site (#10709)

* Animal ears after death (#10691)

* Animal Ears purchasable with Gold if lost in Death

* remove ears from pinned items when set is bought

* standardise css and error handling for gems and coins

* revert accidental new line

* fix client tests

* Reduce margin-bottom of checklist-item from 10px to -3px. (#10684)

* chore(i18n): update locales

* 4.61.1

* feat(content): Subscriber Items and Magic Potions

* chore(sprites): compile

* chore(i18n): update locales

* 4.62.0

* Display notification for users to confirm their username

* fix typo

* WIP(usernames): Changes to address #10694

* WIP(usernames): Further changes for #10694

* fix(usernames): don't show spurious headings

* Change verify username notification to new version

* Improve feedback for invalid usernames

* Allow user to set their username again to confirm it

* Improve validation display for usernames

* Temporarily move display name validation outside of schema

* Improve rendering banner about sleeping in the inn

See #10695

* Display settings in one column

* Position inn banner when window is resized

* Update inn banner handling

* Fix banner offset on initial load

* Fix minor issues.

* Issue: 10660 - Fixed. Changed default to Please Enter A Value (#10718)

* Issue: 10660 - Fixed. Changed default to Please Enter A Value

* Issue: 10660 - Fixed/revision 2 Changed default to Enter A Value

* chore(news): Bailey announcements

* chore(i18n): update locales

* 4.62.1

* adjust wiki link for usernameInfo string

https://github.com/HabitRPG/habitica-private/issues/7#issuecomment-425405425

* raise coverage for tasks api calls (#10029)

* - updates a group task - approval is required
- updates a group task with checklist

* add expect to test the new checklist length

* - moves tasks to a specified position out of length

* remove unused line

* website getter tasks tests

* re-add sanitizeUserChallengeTask

* change config.json.example variable to be a string not a boolean

* fix tests - pick the text / up/down props too

* fix test - remove changes on text/up/down - revert sanitize condition - revert sanitization props

* Change update username API call

The call no longer requires a password and also validates the username.

* feat(content): Subscriber Items and Magic Potions

* Re-add register call

* Fix merge issue

* Fix issue with setting username

* Implement new alert style

* Display username confirmation status in settings

* Add disclaimer to change username field

* validate username in settings

* Allow specific fields to be focused when opening site settings

* Implement requested changes.

* Fix merge issue

* Fix failing tests

* verify username when users register with username and password

* Set ID for change username notification

* Disable submit button if username is invalid

* Improve username confirmation handling

* refactor(settings): address remaining code comments on auth form

* Revert "refactor(settings): address remaining code comments on auth form"

This reverts commit 9b6609ad64.

* Social user username (#10620)

* Refactored private functions to library

* Refactored social login code

* Added username to social registration

* Changed id library

* Added new local auth check

* Fixed export error. Fixed password check error

* fix(settings): password not available on client

* refactor(settings): more sensible placement of methods

* chore(migration): script to hand out procgen usernames

* fix(migration): don't give EVERYONE new names you doofus

* fix(migration): limit data retrieved, be extra careful about updates

* fix(migration): use missing field, not migration tag, for query

* fix(migration): unused var

* fix(usernames): only generate 20 characters

* fix(migration): set lowerCaseUsername

* fix(lint): comma

* fix(lint): comma spacing

* chore(i18n): update locales

* 4.63.0

* chore(news): Bailey

* chore(i18n): update locales

* 4.63.1

* fix(usernames): various
Reword invalid characters error
Correct typo in slur error
Remove extraneous Confirm button
Reset username field if empty on blur
Restore ability to add local auth to social login

* fix(auth): account for new username paradigm in add-local flow

* fix(auth): alert on successful addLocal

* chore(i18n): update locales

* 4.63.2

* fix(auth): Don't try to check existing username on new reg

* 4.63.3

* feat(content): Armoire and BGs 2018/10

* chore(sprites): compile

* fix(passport): use graph API v2.8

* chore(i18n): update locales

* 4.64.0

* Begin refactoring news API to return individual markdown posts

* Implement simple bailey CMS

* remove old news markdown

* Correctly display images in bailey modal

* Remove need for newStuff migration

* Add basic tests

* Fix authentication issue

* Fix tests

* Update news model

* add API route to get single post

* remove news admin frontend code

* fix lint error

* Fix merge mixups

* Fix lint errors

* fix api call

* fix lint error

* Fix issues caused by merging

* remove console log

* Improve news display

* Correctly update users notifications

* Fix date display for news posts

* Fix tests

* remove old cache file

* correctly create date

* correctly create promise

* Better check for existance.

* Improve docs

* Fix minor issues

* Add method to get latest post

* fix lint errors

* use correct call for 404

* add comment about old newStuff field

* paginate news

* Fix lint errors

* Remove unnecessary await

* Fix broken tests

* ...

* correct existence check

* fix database queries

* change approach to cached news posts

* fix tests

* Change how news posts are cached

* Fetch last news post at an interval

* Fix typos and other small things

* add new permission for modifying bailey posts

* add test for ensureNewsPoster

* return last news post with legacy api

* Fix test

* Hopefully fix test

* change fields to _id

* Fixes

* Fixes

* fix test

* Fixes

* make all tests pass

* fix lint

* id -> _id

* _id -> id

* remove identical tell me later route from api v4

* fix lint

* user model: fix issues with newStuff

* improve user#toJSONTransform

* fix typo

* improve newsPost.js

* fix(integration tests): do not return flags.newStuff if it was not selected

* fix news controller

* server side fixes, start refactoring client

* more client fixes

* automatically set author

* new stuff: show one post per user + drafts

* change default border radius for modals to 8px

* required fields and defaults

* slit news into its own component and fix static page

* noNewsPoster: move from i18n to apiError

* remove unused strings

* fix unit tests

* update apidocs

* add backward comparibility for flags.newStuff in api v3

* fix integration tests

* POST news: make integration test independent of number of posts

* api v3 news: render markdown

* static new-stuff: add padding and fix when user not logged in

* test flags.newStuff

* api v3: test setting flags.newStuff on PUT /user

* refactor news post cache and add tests

* remove new locales file

* more resilient tests

* more resilient tests

* refactor tests for NewsPost.updateLastNewsPost

* api v4: fix tests

* api v3: fix tests

* can set flags.newStuff in api v4

Co-authored-by: Keith Holliday <keithrholliday@gmail.com>
Co-authored-by: Sabe Jones <sabrecat@gmail.com>
Co-authored-by: Alys <Alys@users.noreply.github.com>
Co-authored-by: Matteo Pagliazzi <matteopagliazzi@gmail.com>
Co-authored-by: Carl Vuorinen <carl.vuorinen@gmail.com>
Co-authored-by: Rene Cordier <rene.cordier@gmail.com>
Co-authored-by: Forrest Hatfield <github@forresthatfield.com>
Co-authored-by: lucubro <88whacko@gmail.com>
Co-authored-by: negue <negue@users.noreply.github.com>
Co-authored-by: Alys <alice.harris@oldgods.net>
Co-authored-by: J.D. Sandifer <sandifer.jd@gmail.com>
Co-authored-by: Kirsty <kirsty-tortoise@users.noreply.github.com>
Co-authored-by: beatscribe <rattjp@gmail.com>
Co-authored-by: Phillip Thelen <phillip@habitica.com>
This commit is contained in:
Phillip Thelen
2020-10-13 17:15:52 +02:00
committed by GitHub
parent 97ef3b1d4b
commit d9e774dd77
32 changed files with 1298 additions and 233 deletions

View File

@@ -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 } } };

View File

@@ -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);
});
});
});

View File

@@ -1,9 +1,11 @@
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', () => {
describe('.toJSON()', () => {
it('keeps user._tmp when calling .toJSON', () => {
const user = new User({
auth: {
@@ -83,6 +85,7 @@ describe('User Model', () => {
expect(userToJSON.stats.maxHealth).to.equal(common.maxHealth);
expect(userToJSON.stats.toNextLevel).to.equal(common.tnl(user.stats.lvl));
});
});
context('achievements', () => {
it('can add an achievement', () => {
@@ -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);
});
});
});

View File

@@ -4,7 +4,6 @@ import {
describe('GET /news', () => {
let api;
beforeEach(async () => {
api = requester();
});

View File

@@ -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];

View File

@@ -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', () => {

View File

@@ -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;
});
});

View File

@@ -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;
});
});

View File

@@ -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);
});
});

View File

@@ -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',
});
});
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});
});

View File

@@ -5,6 +5,10 @@
padding-left: 0px !important;
}
.modal-content {
border-radius: 8px;
}
.modal-dialog {
margin: 3rem auto 3rem;
width: auto;

View File

@@ -1,81 +0,0 @@
<template>
<b-modal
id="new-stuff"
size="lg"
:hide-header="true"
:hide-footer="true"
no-close-on-esc="no-close-on-esc"
no-close-on-backdrop="no-close-on-backdrop"
>
<div class="modal-body">
<div
class="static-view"
v-html="html"
></div>
</div>
<div class="modal-footer d-flex align-items-center pb-0">
<a
href="http://habitica.fandom.com/wiki/Whats_New"
target="_blank"
class="mr-auto"
>{{ this.$t('newsArchive') }}</a>
<button
class="btn btn-secondary ml-auto"
@click="tellMeLater()"
>
{{ this.$t('tellMeLater') }}
</button>
<button
class="btn btn-primary"
@click="dismissAlert();"
>
{{ this.$t('dismissAlert') }}
</button>
</div>
</b-modal>
</template>
<style lang='scss'>
@import '~@/assets/scss/static.scss';
#new-stuff {
.modal-body .modal-body {
padding-top: 0rem;
}
}
</style>
<script>
import axios from 'axios';
import { mapState } from '@/libs/store';
export default {
data () {
return {
html: '',
};
},
computed: {
...mapState({ user: 'user.data' }),
},
async mounted () {
this.$root.$on('bv::show::modal', async modalId => {
if (modalId !== 'new-stuff') return;
const response = await axios.get('/api/v4/news');
this.html = response.data.html;
});
},
beforeDestroy () {
this.$root.$off('bv::show::modal');
},
methods: {
tellMeLater () {
this.$store.dispatch('user:newStuffLater');
this.$root.$emit('bv::hide::modal', 'new-stuff');
},
dismissAlert () {
this.$store.dispatch('user:set', { 'flags.newStuff': false });
this.$root.$emit('bv::hide::modal', 'new-stuff');
},
},
};
</script>

View File

@@ -0,0 +1,58 @@
<template>
<b-modal
id="new-stuff"
size="lg"
:hide-header="true"
:hide-footer="true"
no-close-on-esc
no-close-on-backdrop
@shown="onShow()"
>
<div class="modal-body">
<news-content ref="newsContent" />
</div>
<div class="modal-footer d-flex align-items-center pb-0">
<a
href="http://habitica.fandom.com/wiki/Whats_New"
target="_blank"
class="mr-auto"
>{{ $t('newsArchive') }}</a>
<button
class="btn btn-secondary ml-auto"
@click="tellMeLater()"
>
{{ $t('tellMeLater') }}
</button>
<button
class="btn btn-primary"
@click="dismissAlert()"
>
{{ $t('dismissAlert') }}
</button>
</div>
</b-modal>
</template>
<script>
import newsContent from './newsContent';
export default {
components: {
newsContent,
},
methods: {
async onShow () {
this.$refs.newsContent.getPosts();
},
tellMeLater () {
this.$store.dispatch('news:remindMeLater');
this.$root.$emit('bv::hide::modal', 'new-stuff');
},
dismissAlert () {
this.$store.dispatch('news:markAsRead');
this.$root.$emit('bv::hide::modal', 'new-stuff');
},
},
};
</script>

View File

@@ -0,0 +1,107 @@
<template>
<div>
<div class="bailey-header d-flex align-items-center mb-3">
<div class="npc_bailey mr-3"></div>
<h1 v-once>
{{ $t('newStuff') }}
</h1>
</div>
<div
v-for="(post, index) in posts"
:key="post._id"
class="static-view bailey"
:class="{'bailey-last': index == (posts.length - 1)}"
>
<small
v-if="!post.published"
class="draft"
>DRAFT</small>
<h2 class="title">
{{ getPostDate(post) }} - {{ post.title.toUpperCase() }}
</h2>
<hr>
<div v-html="renderMarkdown(post.text)"></div>
<small>by {{ post.credits }}</small>
</div>
</div>
</template>
<style lang='scss'>
@import '~@/assets/scss/static.scss';
</style>
<style lang='scss' scoped>
@import '~@/assets/scss/colors.scss';
h1 {
color: $purple-200;
margin-bottom: 0;
}
.bailey {
margin-bottom: 1rem;
&.bailey-last {
margin-bottom: 0;
}
.title {
display: inline;
}
.draft {
margin-right: 10px;
}
h2 {
color: $purple-200;
}
}
</style>
<script>
import moment from 'moment';
import habiticaMarkdown from 'habitica-markdown';
import { mapState } from '@/libs/store';
export default {
data () {
return {
posts: [],
};
},
computed: {
...mapState({ user: 'user.data' }),
},
methods: {
async getPosts () {
const postsFromServer = await this.$store.dispatch('news:fetch');
// Show the last published post + any draft for the authorized users
this.posts = [];
const lastPublishedPost = postsFromServer
.find(p => p.published && moment().isAfter(p.publishDate));
if (lastPublishedPost) this.posts.push(lastPublishedPost);
// If the user is authorized, show any draft
if (this.user && this.user.contributor.newsPoster) {
this.posts.unshift(
...postsFromServer
.filter(p => !p.published || moment().isBefore(p.publishDate)),
);
}
},
renderMarkdown (text) {
return habiticaMarkdown.unsafeHTMLRender(text);
},
getPostDate (post) {
const format = this.user ? this.user.preferences.dateFormat.toUpperCase() : 'MM/DD/yyyy';
return moment(post.publishedDate).format(format);
},
},
};
</script>

View File

@@ -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';

View File

@@ -1,26 +1,18 @@
<template>
<div
class="static-view"
v-html="html"
></div>
<div class="p-2">
<news-content ref="newsContent" />
</div>
</template>
<style lang='scss'>
@import '~@/assets/scss/static.scss';
</style>
<script>
import axios from 'axios';
import newsContent from '../news/newsContent';
export default {
data () {
return {
html: '',
};
components: {
newsContent,
},
async mounted () {
const response = await axios.get('/api/v4/news');
this.html = response.data.html;
mounted () {
this.$refs.newsContent.getPosts();
},
};
</script>

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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');

View File

@@ -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."
}

View File

@@ -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.',

View File

@@ -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,6 +23,8 @@ api.getNews = {
async handler (req, res) {
const baileyClass = worldDmg.bailey ? 'npc_bailey_broken' : 'npc_bailey';
const lastNewsPost = NewsPost.lastNewsPost();
if (lastNewsPost) {
res.status(200).send({
html: `
<div class="bailey">
@@ -31,44 +32,33 @@ api.getNews = {
<div class="mr-3 ${baileyClass}"></div>
<div class="media-body">
<h1 class="align-self-center">${res.t('newStuff')}</h1>
<h2>10/1/2020 - ${LAST_ANNOUNCEMENT_TITLE}</h2>
<h2>${lastNewsPost.title.toUpperCase()}</h2>
</div>
</div>
<hr/>
<div class="promo_armoire_backgrounds_202010 center-block"></div>
<h3>October Backgrounds and Armoire Items!</h3>
<p>
Weve 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!
${md.unsafeHTMLRender(lastNewsPost.text)}
</p>
<p>
Plus, theres 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 :)
</p>
<div class="small mb-3">by AnnDeLune and SabreCat</div>
<div class="promo_spooky_sparkles center-block"></div>
<h3>Spooky Sparkles in Seasonal Shop</h3>
<p>
There's a new Gold-purchasable item in the <a href='/shops/seasonal'>Seasonal Shop</a>:
Spooky Sparkles! Buy some and then cast it on your friends. I wonder what it will do?
</p>
<p>
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!
</p>
<p>
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.
</p>
<div class="small mb-3">by Lemoness and SabreCat</div>
<div class="small">
by ${lastNewsPost.credits}
</div>
</div>
`,
});
} else {
res.status(200).send({
html: `
<div class="bailey">
<div class="media align-items-center">
<div class="mr-3 ${baileyClass}"></div>
<div class="media-body">
<h1 class="align-self-center">${res.t('newStuff')}</h1>
</div>
</div>
</div>
`,
});
}
},
};
@@ -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
user.addNotification('NEW_STUFF', { title: lastNewsPost.title.toUpperCase() }, true); // seen by default
await user.save();
}
res.respond(200, {});
},
};

View File

@@ -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 <a href="https://github.com/HabitRPG/habitica/blob/develop/website/server/models/newsPost.js" target="_blank">/website/server/models/newsPost.js</a>)
*
* @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 <a href="https://github.com/HabitRPG/habitica/blob/develop/website/server/models/newsPost.js" target="_blank">/website/server/models/newsPost.js</a>)
*
* @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 <a href="https://github.com/HabitRPG/habitica/blob/develop/website/server/models/newsPost.js" target="_blank">/website/server/models/newsPost.js</a>)
*
* @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;

View File

@@ -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 }));
}

View File

@@ -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',

View File

@@ -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;

View File

@@ -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);

View File

@@ -31,6 +31,10 @@ schema.plugin(baseModel, {
delete plainObj.filters;
if (plainObj.flags && originalDoc.isSelected('flags.lastNewStuffRead')) {
plainObj.flags.newStuff = originalDoc.checkNewStuff();
}
return plainObj;
},
});

View File

@@ -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

View File

@@ -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,