diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index 458fed6f10..0ee5f710b6 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -81,6 +81,49 @@ describe('POST /tasks/:id/score/:direction', () => { expect(body.direction).to.eql('up'); expect(body.delta).to.be.greaterThan(0); }); + + context('sending user activity webhooks', () => { + before(async () => { + await server.start(); + }); + + after(async () => { + await server.close(); + }); + + it('sends user activity webhook when the user levels up', async () => { + let uuid = generateUUID(); + + await user.post('/user/webhook', { + url: `http://localhost:${server.port}/webhooks/${uuid}`, + type: 'userActivity', + enabled: true, + options: { + leveledUp: true, + }, + }); + + const initialLvl = user.stats.lvl; + + await user.update({ + 'stats.exp': 3000, + }); + let task = await user.post('/tasks/user', { + text: 'test habit', + type: 'habit', + }); + + await user.post(`/tasks/${task.id}/score/up`); + await user.sync(); + await sleep(); + + let body = server.getWebhookData(uuid); + + expect(body.type).to.eql('leveledUp'); + expect(body.initialLvl).to.eql(initialLvl); + expect(body.finalLvl).to.eql(user.stats.lvl); + }); + }); }); context('todos', () => { diff --git a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js index 56662bcda5..00c238208a 100644 --- a/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js +++ b/test/api/v3/integration/tasks/checklists/POST-tasks_taskId_checklist_itemId_score.test.js @@ -1,6 +1,8 @@ import { generateUser, translate as t, + server, + sleep, } from '../../../../../helpers/api-integration/v3'; import { v4 as generateUUID } from 'uuid'; @@ -94,4 +96,49 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => { message: t('checklistItemNotFound'), }); }); + + context('sending task activity webhooks', () => { + before(async () => { + await server.start(); + }); + + after(async () => { + await server.close(); + }); + + it('sends task activity webhooks', async () => { + let uuid = generateUUID(); + + await user.post('/user/webhook', { + url: `http://localhost:${server.port}/webhooks/${uuid}`, + type: 'taskActivity', + enabled: true, + options: { + checklistScored: true, + updated: false, + }, + }); + + let task = await user.post('/tasks/user', { + text: 'test daily', + type: 'daily', + }); + + let updatedTask = await user.post(`/tasks/${task.id}/checklist`, { + text: 'checklist item text', + }); + + let checklistItem = updatedTask.checklist[0]; + + let scoredItemTask = await user.post(`/tasks/${task.id}/checklist/${checklistItem.id}/score`); + + await sleep(); + + let body = server.getWebhookData(uuid); + + expect(body.type).to.eql('checklistScored'); + expect(body.task).to.eql(scoredItemTask); + expect(body.item).to.eql(scoredItemTask.checklist[0]); + }); + }); }); diff --git a/test/api/v3/integration/user/POST-user_feed_pet_food.test.js b/test/api/v3/integration/user/POST-user_feed_pet_food.test.js index e3a6574157..5026416795 100644 --- a/test/api/v3/integration/user/POST-user_feed_pet_food.test.js +++ b/test/api/v3/integration/user/POST-user_feed_pet_food.test.js @@ -3,8 +3,11 @@ import { generateUser, translate as t, + server, + sleep, } from '../../../../helpers/api-integration/v3'; import content from '../../../../../website/common/script/content'; +import { v4 as generateUUID } from 'uuid'; describe('POST /user/feed/:pet/:food', () => { let user; @@ -37,4 +40,41 @@ describe('POST /user/feed/:pet/:food', () => { expect(user.items.food.Milk).to.equal(1); expect(user.items.pets['Wolf-Base']).to.equal(7); }); + + context('sending user activity webhooks', () => { + before(async () => { + await server.start(); + }); + + after(async () => { + await server.close(); + }); + + it('sends user activity webhook when a new mount is raised', async () => { + let uuid = generateUUID(); + + await user.post('/user/webhook', { + url: `http://localhost:${server.port}/webhooks/${uuid}`, + type: 'userActivity', + enabled: true, + options: { + mountRaised: true, + }, + }); + + await user.update({ + 'items.pets.Wolf-Base': 49, + 'items.food.Milk': 2, + }); + let res = await user.post('/user/feed/Wolf-Base/Milk'); + + await sleep(); + + let body = server.getWebhookData(uuid); + + expect(body.type).to.eql('mountRaised'); + expect(body.pet).to.eql('Wolf-Base'); + expect(body.message).to.eql(res.message); + }); + }); }); diff --git a/test/api/v3/integration/user/POST-user_hatch_egg_hatchingPotion.test.js b/test/api/v3/integration/user/POST-user_hatch_egg_hatchingPotion.test.js index 9621377beb..2aaa54e5e5 100644 --- a/test/api/v3/integration/user/POST-user_hatch_egg_hatchingPotion.test.js +++ b/test/api/v3/integration/user/POST-user_hatch_egg_hatchingPotion.test.js @@ -1,7 +1,10 @@ import { generateUser, translate as t, + server, + sleep, } from '../../../../helpers/api-integration/v3'; +import { v4 as generateUUID } from 'uuid'; describe('POST /user/hatch/:egg/:hatchingPotion', () => { let user; @@ -28,4 +31,41 @@ describe('POST /user/hatch/:egg/:hatchingPotion', () => { data: JSON.parse(JSON.stringify(user.items)), }); }); + + context('sending user activity webhooks', () => { + before(async () => { + await server.start(); + }); + + after(async () => { + await server.close(); + }); + + it('sends user activity webhook when a new pet is hatched', async () => { + let uuid = generateUUID(); + + await user.post('/user/webhook', { + url: `http://localhost:${server.port}/webhooks/${uuid}`, + type: 'userActivity', + enabled: true, + options: { + petHatched: true, + }, + }); + + await user.update({ + 'items.eggs.Wolf': 1, + 'items.hatchingPotions.Base': 1, + }); + let res = await user.post('/user/hatch/Wolf/Base'); + + await sleep(); + + let body = server.getWebhookData(uuid); + + expect(body.type).to.eql('petHatched'); + expect(body.pet).to.eql('Wolf-Base'); + expect(body.message).to.eql(res.message); + }); + }); }); diff --git a/test/api/v3/integration/webhook/POST-user_add_webhook.test.js b/test/api/v3/integration/webhook/POST-user_add_webhook.test.js index accb834dc4..1c654e1705 100644 --- a/test/api/v3/integration/webhook/POST-user_add_webhook.test.js +++ b/test/api/v3/integration/webhook/POST-user_add_webhook.test.js @@ -116,6 +116,7 @@ describe('POST /user/webhook', () => { let webhook = await user.post('/user/webhook', body); expect(webhook.options).to.eql({ + checklistScored: false, created: false, updated: false, deleted: false, @@ -126,6 +127,7 @@ describe('POST /user/webhook', () => { it('can set taskActivity options', async () => { body.type = 'taskActivity'; body.options = { + checklistScored: true, created: true, updated: true, deleted: true, @@ -135,6 +137,7 @@ describe('POST /user/webhook', () => { let webhook = await user.post('/user/webhook', body); expect(webhook.options).to.eql({ + checklistScored: true, created: true, updated: true, deleted: true, @@ -145,6 +148,7 @@ describe('POST /user/webhook', () => { it('discards extra properties in taskActivity options', async () => { body.type = 'taskActivity'; body.options = { + checklistScored: false, created: true, updated: true, deleted: true, @@ -156,6 +160,7 @@ describe('POST /user/webhook', () => { expect(webhook.options.foo).to.not.exist; expect(webhook.options).to.eql({ + checklistScored: false, created: true, updated: true, deleted: true, @@ -218,4 +223,16 @@ describe('POST /user/webhook', () => { groupId: body.options.groupId, }); }); + + it('discards extra properties in globalActivity options', async () => { + body.type = 'globalActivity'; + body.options = { + foo: 'bar', + }; + + let webhook = await user.post('/user/webhook', body); + + expect(webhook.options.foo).to.not.exist; + expect(webhook.options).to.eql({}); + }); }); diff --git a/test/api/v3/integration/webhook/PUT-user_update_webhook.test.js b/test/api/v3/integration/webhook/PUT-user_update_webhook.test.js index 994eff6750..e388b8610b 100644 --- a/test/api/v3/integration/webhook/PUT-user_update_webhook.test.js +++ b/test/api/v3/integration/webhook/PUT-user_update_webhook.test.js @@ -95,6 +95,7 @@ describe('PUT /user/webhook/:id', () => { let webhook = await user.put(`/user/webhook/${webhookToUpdate.id}`, {type, options}); expect(webhook.options).to.eql({ + checklistScored: false, // starting value created: true, // starting value updated: false, deleted: true, diff --git a/test/api/v3/unit/libs/webhooks.test.js b/test/api/v3/unit/libs/webhooks.test.js index 467ce9bd8a..9e010c45b6 100644 --- a/test/api/v3/unit/libs/webhooks.test.js +++ b/test/api/v3/unit/libs/webhooks.test.js @@ -4,11 +4,16 @@ import { taskScoredWebhook, groupChatReceivedWebhook, taskActivityWebhook, + questActivityWebhook, + userActivityWebhook, } from '../../../../../website/server/libs/webhook'; +import { + generateUser, +} from '../../../../helpers/api-unit.helper.js'; import { defer } from '../../../../helpers/api-unit.helper'; describe('webhooks', () => { - let webhooks; + let webhooks, user; beforeEach(() => { sandbox.stub(got, 'post').returns(defer().promise); @@ -23,6 +28,26 @@ describe('webhooks', () => { updated: true, deleted: true, scored: true, + checklistScored: true, + }, + }, { + id: 'questActivity', + url: 'http://quest-activity.com', + enabled: true, + type: 'questActivity', + options: { + questStarted: true, + questFinised: true, + }, + }, { + id: 'userActivity', + url: 'http://user-activity.com', + enabled: true, + type: 'userActivity', + options: { + petHatched: true, + mountRaised: true, + leveledUp: true, }, }, { id: 'groupChatReceived', @@ -33,6 +58,9 @@ describe('webhooks', () => { groupId: 'group-id', }, }]; + + user = generateUser(); + user.webhooks = webhooks; }); afterEach(() => { @@ -57,7 +85,8 @@ describe('webhooks', () => { let body = { foo: 'bar' }; - sendWebhook.send([{id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom'}], body); + user.webhooks = [{id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom'}]; + sendWebhook.send(user, body); expect(WebhookSender.defaultTransformData).to.be.calledOnce; expect(got.post).to.be.calledOnce; @@ -67,6 +96,30 @@ describe('webhooks', () => { }); }); + it('adds default data (user and webhookType) to the body', () => { + let sendWebhook = new WebhookSender({ + type: 'custom', + }); + sandbox.spy(sendWebhook, 'attachDefaultData'); + + let body = { foo: 'bar' }; + + user.webhooks = [{id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom'}]; + sendWebhook.send(user, body); + + expect(sendWebhook.attachDefaultData).to.be.calledOnce; + expect(got.post).to.be.calledOnce; + expect(got.post).to.be.calledWithMatch('http://custom-url.com', { + json: true, + }); + + expect(body).to.eql({ + foo: 'bar', + user: {_id: user._id}, + webhookType: 'custom', + }); + }); + it('can pass in a data transformation function', () => { sandbox.spy(WebhookSender, 'defaultTransformData'); let sendWebhook = new WebhookSender({ @@ -80,7 +133,8 @@ describe('webhooks', () => { let body = { foo: 'bar' }; - sendWebhook.send([{id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom'}], body); + user.webhooks = [{id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom'}]; + sendWebhook.send(user, body); expect(WebhookSender.defaultTransformData).to.not.be.called; expect(got.post).to.be.calledOnce; @@ -93,7 +147,7 @@ describe('webhooks', () => { }); }); - it('provieds a default filter function', () => { + it('provides a default filter function', () => { sandbox.spy(WebhookSender, 'defaultWebhookFilter'); let sendWebhook = new WebhookSender({ type: 'custom', @@ -101,7 +155,8 @@ describe('webhooks', () => { let body = { foo: 'bar' }; - sendWebhook.send([{id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom'}], body); + user.webhooks = [{id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom'}]; + sendWebhook.send(user, body); expect(WebhookSender.defaultWebhookFilter).to.be.calledOnce; }); @@ -117,7 +172,8 @@ describe('webhooks', () => { let body = { foo: 'bar' }; - sendWebhook.send([{id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom'}], body); + user.webhooks = [{id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom'}]; + sendWebhook.send(user, body); expect(WebhookSender.defaultWebhookFilter).to.not.be.called; expect(got.post).to.not.be.called; @@ -134,10 +190,11 @@ describe('webhooks', () => { let body = { foo: 'bar' }; - sendWebhook.send([ + user.webhooks = [ { id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom', options: { foo: 'bar' }}, { id: 'other-custom-webhook', url: 'http://other-custom-url.com', enabled: true, type: 'custom', options: { foo: 'foo' }}, - ], body); + ]; + sendWebhook.send(user, body); expect(got.post).to.be.calledOnce; expect(got.post).to.be.calledWithMatch('http://custom-url.com'); @@ -150,7 +207,8 @@ describe('webhooks', () => { let body = { foo: 'bar' }; - sendWebhook.send([{id: 'custom-webhook', url: 'http://custom-url.com', enabled: false, type: 'custom'}], body); + user.webhooks = [{id: 'custom-webhook', url: 'http://custom-url.com', enabled: false, type: 'custom'}]; + sendWebhook.send(user, body); expect(got.post).to.not.be.called; }); @@ -162,7 +220,8 @@ describe('webhooks', () => { let body = { foo: 'bar' }; - sendWebhook.send([{id: 'custom-webhook', url: 'httxp://custom-url!!', enabled: true, type: 'custom'}], body); + user.webhooks = [{id: 'custom-webhook', url: 'httxp://custom-url!!!', enabled: true, type: 'custom'}]; + sendWebhook.send(user, body); expect(got.post).to.not.be.called; }); @@ -174,10 +233,30 @@ describe('webhooks', () => { let body = { foo: 'bar' }; - sendWebhook.send([ + user.webhooks = [ { id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom'}, { id: 'other-webhook', url: 'http://other-url.com', enabled: true, type: 'other'}, - ], body); + ]; + sendWebhook.send(user, body); + + expect(got.post).to.be.calledOnce; + expect(got.post).to.be.calledWithMatch('http://custom-url.com', { + body, + json: true, + }); + }); + + it('sends every type of activity to global webhooks', () => { + let sendWebhook = new WebhookSender({ + type: 'custom', + }); + + let body = { foo: 'bar' }; + + user.webhooks = [ + { id: 'global-webhook', url: 'http://custom-url.com', enabled: true, type: 'globalActivity'}, + ]; + sendWebhook.send(user, body); expect(got.post).to.be.calledOnce; expect(got.post).to.be.calledWithMatch('http://custom-url.com', { @@ -193,10 +272,11 @@ describe('webhooks', () => { let body = { foo: 'bar' }; - sendWebhook.send([ + user.webhooks = [ { id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom'}, { id: 'other-custom-webhook', url: 'http://other-url.com', enabled: true, type: 'custom'}, - ], body); + ]; + sendWebhook.send(user, body); expect(got.post).to.be.calledTwice; expect(got.post).to.be.calledWithMatch('http://custom-url.com', { @@ -216,7 +296,6 @@ describe('webhooks', () => { beforeEach(() => { data = { user: { - _id: 'user-id', _tmp: {foo: 'bar'}, stats: { lvl: 5, @@ -248,15 +327,54 @@ describe('webhooks', () => { }); it('sends task and stats data', () => { - taskScoredWebhook.send(webhooks, data); + taskScoredWebhook.send(user, data); expect(got.post).to.be.calledOnce; expect(got.post).to.be.calledWithMatch(webhooks[0].url, { json: true, body: { type: 'scored', + webhookType: 'taskActivity', user: { - _id: 'user-id', + _id: user._id, + _tmp: {foo: 'bar'}, + stats: { + lvl: 5, + int: 10, + str: 5, + exp: 423, + toNextLevel: 40, + maxHealth: 50, + maxMP: 103, + }, + }, + task: { + text: 'text', + }, + direction: 'up', + delta: 176, + }, + }); + }); + + it('sends task and stats data to globalActivity webhookd', () => { + user.webhooks = [{ + id: 'globalActivity', + url: 'http://global-activity.com', + enabled: true, + type: 'globalActivity', + }]; + + taskScoredWebhook.send(user, data); + + expect(got.post).to.be.calledOnce; + expect(got.post).to.be.calledWithMatch('http://global-activity.com', { + json: true, + body: { + type: 'scored', + webhookType: 'taskActivity', + user: { + _id: user._id, _tmp: {foo: 'bar'}, stats: { lvl: 5, @@ -280,7 +398,7 @@ describe('webhooks', () => { it('does not send task scored data if scored option is not true', () => { webhooks[0].options.scored = false; - taskScoredWebhook.send(webhooks, data); + taskScoredWebhook.send(user, data); expect(got.post).to.not.be.called; }); @@ -301,13 +419,17 @@ describe('webhooks', () => { it(`sends ${type} tasks`, () => { data.type = type; - taskActivityWebhook.send(webhooks, data); + taskActivityWebhook.send(user, data); expect(got.post).to.be.calledOnce; expect(got.post).to.be.calledWithMatch(webhooks[0].url, { json: true, body: { type, + webhookType: 'taskActivity', + user: { + _id: user._id, + }, task: data.task, }, }); @@ -317,7 +439,142 @@ describe('webhooks', () => { data.type = type; webhooks[0].options[type] = false; - taskActivityWebhook.send(webhooks, data); + taskActivityWebhook.send(user, data); + + expect(got.post).to.not.be.called; + }); + }); + + describe('checklistScored', () => { + beforeEach(() => { + data = { + task: { + text: 'text', + }, + item: { + text: 'item-text', + }, + }; + }); + + it('sends \'checklistScored\' tasks', () => { + data.type = 'checklistScored'; + + taskActivityWebhook.send(user, data); + + expect(got.post).to.be.calledOnce; + expect(got.post).to.be.calledWithMatch(webhooks[0].url, { + json: true, + body: { + webhookType: 'taskActivity', + user: { + _id: user._id, + }, + type: data.type, + task: data.task, + item: data.item, + }, + }); + }); + + it('does not send task \'checklistScored\' data if \'checklistScored\' option is not true', () => { + data.type = 'checklistScored'; + webhooks[0].options.checklistScored = false; + + taskActivityWebhook.send(user, data); + + expect(got.post).to.not.be.called; + }); + }); + }); + + describe('userActivityWebhook', () => { + let data; + + beforeEach(() => { + data = { + something: true, + }; + }); + + ['petHatched', 'mountRaised', 'leveledUp'].forEach((type) => { + it(`sends ${type} webhooks`, () => { + data.type = type; + + userActivityWebhook.send(user, data); + + expect(got.post).to.be.calledOnce; + expect(got.post).to.be.calledWithMatch(webhooks[2].url, { + json: true, + body: { + type, + webhookType: 'userActivity', + user: { + _id: user._id, + }, + something: true, + }, + }); + }); + + it(`does not send webhook ${type} data if ${type} option is not true`, () => { + data.type = type; + webhooks[2].options[type] = false; + + userActivityWebhook.send(user, data); + + expect(got.post).to.not.be.called; + }); + }); + }); + + describe('questActivityWebhook', () => { + let data; + + beforeEach(() => { + data = { + group: { + id: 'group-id', + name: 'some group', + otherData: 'foo', + }, + quest: { + key: 'some-key', + }, + }; + }); + + ['questStarted', 'questFinised'].forEach((type) => { + it(`sends ${type} webhooks`, () => { + data.type = type; + + questActivityWebhook.send(user, data); + + expect(got.post).to.be.calledOnce; + expect(got.post).to.be.calledWithMatch(webhooks[1].url, { + json: true, + body: { + type, + webhookType: 'questActivity', + user: { + _id: user._id, + }, + group: { + id: 'group-id', + name: 'some group', + }, + quest: { + key: 'some-key', + }, + }, + }); + }); + + it(`does not send webhook ${type} data if ${type} option is not true`, () => { + data.type = type; + webhooks[1].options[type] = false; + + userActivityWebhook.send(user, data); expect(got.post).to.not.be.called; }); @@ -338,12 +595,16 @@ describe('webhooks', () => { }, }; - groupChatReceivedWebhook.send(webhooks, data); + groupChatReceivedWebhook.send(user, data); expect(got.post).to.be.calledOnce; expect(got.post).to.be.calledWithMatch(webhooks[webhooks.length - 1].url, { json: true, body: { + webhookType: 'groupChatReceived', + user: { + _id: user._id, + }, group: { id: 'group-id', name: 'some group', @@ -369,7 +630,7 @@ describe('webhooks', () => { }, }; - groupChatReceivedWebhook.send(webhooks, data); + groupChatReceivedWebhook.send(user, data); expect(got.post).to.not.be.called; }); diff --git a/test/api/v3/unit/models/group.test.js b/test/api/v3/unit/models/group.test.js index 589a92e5b5..82547341a6 100644 --- a/test/api/v3/unit/models/group.test.js +++ b/test/api/v3/unit/models/group.test.js @@ -11,7 +11,10 @@ import { } from '../../../../../website/server/models/group'; import { model as User } from '../../../../../website/server/models/user'; import { quests as questScrolls } from '../../../../../website/common/script/content'; -import { groupChatReceivedWebhook } from '../../../../../website/server/libs/webhook'; +import { + groupChatReceivedWebhook, + questActivityWebhook, +} from '../../../../../website/server/libs/webhook'; import * as email from '../../../../../website/server/libs/email'; import { TAVERN_ID } from '../../../../../website/common/script/'; import shared from '../../../../../website/common'; @@ -21,6 +24,7 @@ describe('Group Model', () => { beforeEach(async () => { sandbox.stub(email, 'sendTxn'); + sandbox.stub(questActivityWebhook, 'send'); party = new Group({ name: 'test party', @@ -1189,6 +1193,47 @@ describe('Group Model', () => { expect(typeOfEmail).to.eql('quest-started'); }); + it('sends webhook to participating members that quest has started', async () => { + // should receive webhook + participatingMember.webhooks = [{ + type: 'questActivity', + url: 'http://someurl.com', + options: { + questStarted: true, + }, + }]; + questLeader.webhooks = [{ + type: 'questActivity', + url: 'http://someurl.com', + options: { + questStarted: true, + }, + }]; + + await Promise.all([participatingMember.save(), questLeader.save()]); + + await party.startQuest(nonParticipatingMember); + + await sleep(0.5); + + expect(questActivityWebhook.send).to.be.calledTwice; // for 2 participating members + + let args = questActivityWebhook.send.args[0]; + let webhooks = args[0].webhooks; + let webhookOwner = args[0]._id; + let options = args[1]; + + expect(webhooks).to.have.a.lengthOf(1); + if (webhookOwner === questLeader._id) { + expect(webhooks[0].id).to.eql(questLeader.webhooks[0].id); + } else { + expect(webhooks[0].id).to.eql(participatingMember.webhooks[0].id); + } + expect(webhooks[0].type).to.eql('questActivity'); + expect(options.group).to.eql(party); + expect(options.quest.key).to.eql('whale'); + }); + it('sends email only to members who have not opted out', async () => { participatingMember.preferences.emailNotifications.questStarted = false; questLeader.preferences.emailNotifications.questStarted = true; @@ -1570,6 +1615,42 @@ describe('Group Model', () => { }); }); + it('sends webhook to participating members that quest has finished', async () => { + // should receive webhook + participatingMember.webhooks = [{ + type: 'questActivity', + url: 'http://someurl.com', + options: { + questFinished: true, + }, + }]; + questLeader.webhooks = [{ + type: 'questActivity', + url: 'http://someurl.com', + options: { + questStarted: true, // will not receive the webhook + }, + }]; + + await Promise.all([participatingMember.save(), questLeader.save()]); + + await party.finishQuest(quest); + + await sleep(0.5); + + expect(questActivityWebhook.send).to.be.calledOnce; + + let args = questActivityWebhook.send.args[0]; + let webhooks = args[0].webhooks; + let options = args[1]; + + expect(webhooks).to.have.a.lengthOf(1); + expect(webhooks[0].id).to.eql(participatingMember.webhooks[0].id); + expect(webhooks[0].type).to.eql('questActivity'); + expect(options.group).to.eql(party); + expect(options.quest.key).to.eql(quest.key); + }); + context('World quests in Tavern', () => { let tavernQuest; @@ -1685,7 +1766,7 @@ describe('Group Model', () => { expect(groupChatReceivedWebhook.send).to.be.calledOnce; let args = groupChatReceivedWebhook.send.args[0]; - let webhooks = args[0]; + let webhooks = args[0].webhooks; let options = args[1]; expect(webhooks).to.have.a.lengthOf(1); @@ -1749,9 +1830,9 @@ describe('Group Model', () => { expect(groupChatReceivedWebhook.send).to.be.calledThrice; let args = groupChatReceivedWebhook.send.args; - expect(args.find(arg => arg[0][0].id === memberWithWebhook.webhooks[0].id)).to.be.exist; - expect(args.find(arg => arg[0][0].id === memberWithWebhook2.webhooks[0].id)).to.be.exist; - expect(args.find(arg => arg[0][0].id === memberWithWebhook3.webhooks[0].id)).to.be.exist; + expect(args.find(arg => arg[0].webhooks[0].id === memberWithWebhook.webhooks[0].id)).to.be.exist; + expect(args.find(arg => arg[0].webhooks[0].id === memberWithWebhook2.webhooks[0].id)).to.be.exist; + expect(args.find(arg => arg[0].webhooks[0].id === memberWithWebhook3.webhooks[0].id)).to.be.exist; }); }); diff --git a/test/api/v3/unit/models/webhook.test.js b/test/api/v3/unit/models/webhook.test.js index ddb63e9caf..1917330a9f 100644 --- a/test/api/v3/unit/models/webhook.test.js +++ b/test/api/v3/unit/models/webhook.test.js @@ -24,6 +24,7 @@ describe('Webhook Model', () => { updated: true, deleted: true, scored: true, + checklistScored: true, }, }; }); @@ -36,6 +37,7 @@ describe('Webhook Model', () => { wh.formatOptions(res); expect(wh.options).to.eql({ + checklistScored: false, created: false, updated: false, deleted: false, @@ -51,6 +53,7 @@ describe('Webhook Model', () => { wh.formatOptions(res); expect(wh.options).to.eql({ + checklistScored: true, created: false, updated: true, deleted: true, @@ -67,6 +70,7 @@ describe('Webhook Model', () => { expect(wh.options.foo).to.not.exist; expect(wh.options).to.eql({ + checklistScored: true, created: true, updated: true, deleted: true, @@ -74,7 +78,155 @@ describe('Webhook Model', () => { }); }); - ['created', 'updated', 'deleted', 'scored'].forEach((option) => { + ['created', 'updated', 'deleted', 'scored', 'checklistScored'].forEach((option) => { + it(`validates that ${option} is a boolean`, (done) => { + config.options[option] = 'not a boolean'; + + try { + let wh = new Webhook(config); + + wh.formatOptions(res); + } catch (err) { + expect(err).to.be.an.instanceOf(BadRequest); + expect(res.t).to.be.calledOnce; + expect(res.t).to.be.calledWith('webhookBooleanOption', { option }); + done(); + } + }); + }); + }); + + context('type is userActivity', () => { + let config; + + beforeEach(() => { + config = { + type: 'userActivity', + url: 'https//exmaple.com/endpoint', + options: { + petHatched: true, + mountRaised: true, + leveledUp: true, + }, + }; + }); + + it('it provides default values for options', () => { + delete config.options; + + let wh = new Webhook(config); + + wh.formatOptions(res); + + expect(wh.options).to.eql({ + petHatched: false, + mountRaised: false, + leveledUp: false, + }); + }); + + it('provides missing user options', () => { + delete config.options.petHatched; + + let wh = new Webhook(config); + + wh.formatOptions(res); + + expect(wh.options).to.eql({ + petHatched: false, + mountRaised: true, + leveledUp: true, + }); + }); + + it('discards additional options', () => { + config.options.foo = 'another option'; + + let wh = new Webhook(config); + + wh.formatOptions(res); + + expect(wh.options.foo).to.not.exist; + expect(wh.options).to.eql({ + petHatched: true, + mountRaised: true, + leveledUp: true, + }); + }); + + ['petHatched', 'petHatched', 'leveledUp'].forEach((option) => { + it(`validates that ${option} is a boolean`, (done) => { + config.options[option] = 'not a boolean'; + + try { + let wh = new Webhook(config); + + wh.formatOptions(res); + } catch (err) { + expect(err).to.be.an.instanceOf(BadRequest); + expect(res.t).to.be.calledOnce; + expect(res.t).to.be.calledWith('webhookBooleanOption', { option }); + done(); + } + }); + }); + }); + + context('type is questActivity', () => { + let config; + + beforeEach(() => { + config = { + type: 'questActivity', + url: 'https//exmaple.com/endpoint', + options: { + questStarted: true, + questFinished: true, + }, + }; + }); + + it('it provides default values for options', () => { + delete config.options; + + let wh = new Webhook(config); + + wh.formatOptions(res); + + expect(wh.options).to.eql({ + questStarted: false, + questFinished: false, + }); + }); + + it('provides missing user options', () => { + delete config.options.questStarted; + + let wh = new Webhook(config); + + wh.formatOptions(res); + + expect(wh.options).to.eql({ + questStarted: false, + questFinished: true, + }); + }); + + it('discards additional options', () => { + config.options.foo = 'another option'; + + let wh = new Webhook(config); + + wh.formatOptions(res); + + expect(wh.options.foo).to.not.exist; + expect(wh.options).to.eql({ + questStarted: true, + questFinished: true, + }); + }); + + ['questStarted', 'questFinished'].forEach((option) => { it(`validates that ${option} is a boolean`, (done) => { config.options[option] = 'not a boolean'; @@ -141,6 +293,30 @@ describe('Webhook Model', () => { } }); }); + + + context('type is globalActivity', () => { + let config; + + beforeEach(() => { + config = { + type: 'globalActivity', + url: 'https//exmaple.com/endpoint', + options: { }, + }; + }); + + it('discards additional objects', () => { + config.options.foo = 'another thing'; + + let wh = new Webhook(config); + + wh.formatOptions(res); + + expect(wh.options.foo).to.not.exist; + expect(wh.options).to.eql({}); + }); + }); }); }); }); diff --git a/test/common/fns/updateStats.test.js b/test/common/fns/updateStats.test.js index e0ca40f98c..60bd489db2 100644 --- a/test/common/fns/updateStats.test.js +++ b/test/common/fns/updateStats.test.js @@ -117,6 +117,18 @@ describe('common.fns.updateStats', () => { expect(user.addNotification).to.be.calledWith('DROPS_ENABLED'); }); + it('add user notification when the user levels up', () => { + const initialLvl = user.stats.lvl; + updateStats(user, { + exp: 3000, + }); + expect(user.addNotification).to.be.calledTwice; // once is for drops enabled + expect(user.addNotification).to.be.calledWith('LEVELED_UP', { + initialLvl, + newLvl: user.stats.lvl, + }); + }); + it('add user notification when rebirth is enabled', () => { user.stats.lvl = 51; updateStats(user, { }); diff --git a/website/common/script/fns/updateStats.js b/website/common/script/fns/updateStats.js index 77d5473413..0538ec3805 100644 --- a/website/common/script/fns/updateStats.js +++ b/website/common/script/fns/updateStats.js @@ -20,6 +20,8 @@ module.exports = function updateStats (user, stats, req = {}, analytics) { if (stats.exp >= experienceToNextLevel) { user.stats.exp = stats.exp; + const initialLvl = user.stats.lvl; + while (stats.exp >= experienceToNextLevel) { stats.exp -= experienceToNextLevel; user.stats.lvl++; @@ -47,6 +49,13 @@ module.exports = function updateStats (user, stats, req = {}, analytics) { } } } + + const newLvl = user.stats.lvl; + + if (user.addNotification) user.addNotification('LEVELED_UP', { + initialLvl, + newLvl, + }); } user.stats.exp = stats.exp; diff --git a/website/server/controllers/api-v3/tasks.js b/website/server/controllers/api-v3/tasks.js index 604e5c90c9..e00b082512 100644 --- a/website/server/controllers/api-v3/tasks.js +++ b/website/server/controllers/api-v3/tasks.js @@ -176,7 +176,7 @@ api.createUserTasks = { }); } - taskActivityWebhook.send(user.webhooks, { + taskActivityWebhook.send(user, { type: 'created', task, }); @@ -502,7 +502,7 @@ api.updateTask = { } else if (group && task.group.id && task.group.assignedUsers.length > 0) { await group.updateTask(savedTask); } else { - taskActivityWebhook.send(user.webhooks, { + taskActivityWebhook.send(user, { type: 'updated', task: savedTask, }); @@ -654,7 +654,7 @@ api.scoreTask = { let resJsonData = _.assign({delta, _tmp: user._tmp}, userStats); res.respond(200, resJsonData); - taskScoredWebhook.send(user.webhooks, { + taskScoredWebhook.send(user, { task, direction, delta, @@ -860,6 +860,12 @@ api.scoreCheckListItem = { let savedTask = await task.save(); res.respond(200, savedTask); + + taskActivityWebhook.send(user, { + type: 'checklistScored', + task: savedTask, + item, + }); }, }; @@ -1326,7 +1332,7 @@ api.deleteTask = { if (challenge) { challenge.removeTask(task); } else { - taskActivityWebhook.send(user.webhooks, { + taskActivityWebhook.send(user, { type: 'deleted', task, }); diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index 389e27f708..b1f51be84f 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -11,6 +11,9 @@ import { import * as Tasks from '../../models/task'; import _ from 'lodash'; import * as passwordUtils from '../../libs/password'; +import { + userActivityWebhook, +} from '../../libs/webhook'; import { getUserInfo, sendTxn as txnEmail, @@ -906,8 +909,19 @@ api.hatch = { async handler (req, res) { let user = res.locals.user; let hatchRes = common.ops.hatch(user, req); + await user.save(); + res.respond(200, ...hatchRes); + + // Send webhook + const petKey = `${req.params.egg}-${req.params.hatchingPotion}`; + + userActivityWebhook.send(user, { + type: 'petHatched', + pet: petKey, + message: hatchRes[1], + }); }, }; @@ -982,8 +996,21 @@ api.feed = { async handler (req, res) { let user = res.locals.user; let feedRes = common.ops.feed(user, req); + await user.save(); + res.respond(200, ...feedRes); + + // Send webhook + const petValue = feedRes[0]; + + if (petValue === -1) { // evolved to mount + userActivityWebhook.send(user, { + type: 'mountRaised', + pet: req.params.pet, + message: feedRes[1], + }); + } }, }; diff --git a/website/server/controllers/api-v3/webhook.js b/website/server/controllers/api-v3/webhook.js index 30455adfb7..0084caea85 100644 --- a/website/server/controllers/api-v3/webhook.js +++ b/website/server/controllers/api-v3/webhook.js @@ -31,7 +31,7 @@ let api = {}; * @apiParam (Body) {String} url The webhook's URL * @apiParam (Body) {String} [label] A label to remind you what this webhook does * @apiParam (Body) {Boolean} [enabled=true] If the webhook should be enabled - * @apiParam (Body) {Sring="taskActivity","groupChatReceived"} [type="taskActivity"] The webhook's type. + * @apiParam (Body) {Sring="taskActivity","groupChatReceived","userActivity"} [type="taskActivity"] The webhook's type. * @apiParam (Body) {Object} [options] The webhook's options. Wil differ depending on type. Required for `groupChatReceived` type. If a webhook supports options, the default values are displayed in the examples below * @apiParamExample {json} Task Activity Example * { diff --git a/website/server/libs/webhook.js b/website/server/libs/webhook.js index 4265abf9ba..691525a95a 100644 --- a/website/server/libs/webhook.js +++ b/website/server/libs/webhook.js @@ -33,11 +33,20 @@ export class WebhookSender { return true; } - send (webhooks, data) { + attachDefaultData (user, body) { + body.webhookType = this.type; + body.user = body.user || {}; + body.user._id = user._id; + } + + send (user, data) { + const webhooks = user.webhooks; + let hooks = webhooks.filter((hook) => { - return isValidWebhook(hook) && - this.type === hook.type && - this.webhookFilter(hook, data); + if (!isValidWebhook(hook)) return false; + if (hook.type === 'globalActivity') return true; + + return this.type === hook.type && this.webhookFilter(hook, data); }); if (hooks.length < 1) { @@ -45,6 +54,7 @@ export class WebhookSender { } let body = this.transformData(data); + this.attachDefaultData(user, body); hooks.forEach((hook) => { sendWebhook(hook.url, body); @@ -65,7 +75,7 @@ export let taskScoredWebhook = new WebhookSender({ let extendedStats = user.addComputedStatsToJSONObj(user.stats.toJSON()); let userData = { - _id: user._id, + // _id: user._id, added automatically when the webhook is sent _tmp: user._tmp, stats: extendedStats, }; @@ -90,6 +100,38 @@ export let taskActivityWebhook = new WebhookSender({ }, }); +export let userActivityWebhook = new WebhookSender({ + type: 'userActivity', + webhookFilter (hook, data) { + let { type } = data; + return hook.options[type]; + }, +}); + +export let questActivityWebhook = new WebhookSender({ + type: 'questActivity', + webhookFilter (hook, data) { + let { type } = data; + return hook.options[type]; + }, + transformData (data) { + let { group, quest, type } = data; + + let dataToSend = { + type, + group: { + id: group.id, + name: group.name, + }, + quest: { + key: quest.key, + }, + }; + + return dataToSend; + }, +}); + export let groupChatReceivedWebhook = new WebhookSender({ type: 'groupChatReceived', webhookFilter (hook, data) { diff --git a/website/server/models/group.js b/website/server/models/group.js index 4ce14550f8..ec6b47fcbd 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -12,7 +12,10 @@ import * as Tasks from './task'; import validator from 'validator'; import { removeFromArray } from '../libs/collectionManipulators'; import payments from '../libs/payments/payments'; -import { groupChatReceivedWebhook } from '../libs/webhook'; +import { + groupChatReceivedWebhook, + questActivityWebhook, +} from '../libs/webhook'; import { InternalServerError, BadRequest, @@ -648,20 +651,24 @@ schema.methods.startQuest = async function startQuest (user) { removeFromArray(nonUserQuestMembers, user._id); // remove any users from quest.members who aren't in the party - let partyId = this._id; - let questMembers = this.quest.members; - await Promise.all(Object.keys(this.quest.members).map(memberId => { - return User.findOne({_id: memberId, 'party._id': partyId}) - .select('_id') - .lean() - .exec() - .then((member) => { - if (!member) { - delete questMembers[memberId]; + // and get the data necessary to send webhooks + const members = []; + + await User.find({ + _id: {$in: Object.keys(this.quest.members)}, + }) + .select('party.quest party._id items.quests auth preferences.emailNotifications preferences.pushNotifications pushDevices profile.name webhooks') + .lean() + .exec() + .then(partyMembers => { + partyMembers.forEach(member => { + if (!member.party || member.party._id !== this._id) { + delete this.quest.members[member._id]; + } else { + members.push(member); } - return; }); - })); + }); if (userIsParticipating) { user.party.quest.key = this.quest.key; @@ -670,20 +677,23 @@ schema.methods.startQuest = async function startQuest (user) { user.markModified('party.quest'); } + const promises = []; + // Remove the quest from the quest leader items (if they are the current user) if (this.quest.leader === user._id) { user.items.quests[this.quest.key] -= 1; user.markModified('items.quests'); + promises.push(user.save()); } else { // another user is starting the quest, update the leader separately - await User.update({_id: this.quest.leader}, { + promises.push(User.update({_id: this.quest.leader}, { $inc: { [`items.quests.${this.quest.key}`]: -1, }, - }).exec(); + }).exec()); } // update the remaining users - await User.update({ + promises.push(User.update({ _id: { $in: nonUserQuestMembers }, }, { $set: { @@ -691,7 +701,9 @@ schema.methods.startQuest = async function startQuest (user) { 'party.quest.progress.down': 0, 'party.quest.completed': null, }, - }, { multi: true }).exec(); + }, { multi: true }).exec()); + + await Promise.all(promises); // update the users who are not participating // Do not block updates @@ -703,38 +715,45 @@ schema.methods.startQuest = async function startQuest (user) { }, }, { multi: true }).exec(); - // send notifications in the background without blocking - User.find( - { _id: { $in: nonUserQuestMembers } }, - 'party.quest items.quests auth.facebook auth.local preferences.emailNotifications preferences.pushNotifications pushDevices profile.name' - ).exec().then((membersToNotify) => { - let membersToEmail = _.filter(membersToNotify, (member) => { - // send push notifications and filter users that disabled emails - return member.preferences.emailNotifications.questStarted !== false && - member._id !== user._id; - }); - sendTxnEmail(membersToEmail, 'quest-started', [ - { name: 'PARTY_URL', content: '/party' }, - ]); - let membersToPush = _.filter(membersToNotify, (member) => { - // send push notifications and filter users that disabled emails - return member.preferences.pushNotifications.questStarted !== false && - member._id !== user._id; - }); - _.each(membersToPush, (member) => { - sendPushNotification(member, - { - title: quest.text(), - message: `${shared.i18n.t('questStarted')}: ${quest.text()}`, - identifier: 'questStarted', - }); - }); - }); const newMessage = this.sendChat(`\`Your quest, ${quest.text('en')}, has started.\``, null, { participatingMembers: this.getParticipatingQuestMembers().join(', '), }); - await newMessage.save(); + + const membersToEmail = []; + const pushTitle = quest.text(); + const pushMessage = `${shared.i18n.t('questStarted')}: ${quest.text()}`; + + // send notifications and webhooks in the background without blocking + members.forEach(member => { + if (member._id !== user._id) { + // send push notifications and filter users that disabled emails + if (member.preferences.emailNotifications.questStarted !== false) { + membersToEmail.push(member); + } + + // send push notifications and filter users that disabled emails + if (member.preferences.pushNotifications.questStarted !== false) { + sendPushNotification(member, { + title: pushTitle, + message: pushMessage, + identifier: 'questStarted', + }); + } + } + + // Send webhooks + questActivityWebhook.send(member, { + type: 'questStarted', + group: this, + quest, + }); + }); + + // Send emails in bulk + sendTxnEmail(membersToEmail, 'quest-started', [ + { name: 'PARTY_URL', content: '/party' }, + ]); }; schema.methods.sendGroupChatReceivedWebhooks = function sendGroupChatReceivedWebhooks (chat) { @@ -755,8 +774,7 @@ schema.methods.sendGroupChatReceivedWebhooks = function sendGroupChatReceivedWeb User.find(query).select({webhooks: 1}).lean().exec().then((users) => { users.forEach((user) => { - let { webhooks } = user; - groupChatReceivedWebhook.send(webhooks, { + groupChatReceivedWebhook.send(user, { group: this, chat, }); @@ -907,6 +925,31 @@ schema.methods.finishQuest = async function finishQuest (quest) { })); } + // Send webhooks in background + // @TODO move the find users part to a worker as well, not just the http request + User.find({ + _id: {$in: participants}, + webhooks: { + $elemMatch: { + type: 'questActivity', + 'options.questFinished': true, + }, + }, + }) + .select('_id webhooks') + .lean() + .exec() + .then(participantsWithWebhook => { + participantsWithWebhook.forEach(participantWithWebhook => { + // Send webhooks + questActivityWebhook.send(participantWithWebhook, { + type: 'questFinished', + group: this, + quest, + }); + }); + }); + return await Promise.all(promises); }; diff --git a/website/server/models/user/hooks.js b/website/server/models/user/hooks.js index 81d4e8b06d..d2ce661b06 100644 --- a/website/server/models/user/hooks.js +++ b/website/server/models/user/hooks.js @@ -6,6 +6,9 @@ import * as Tasks from '../task'; import { model as UserNotification, } from '../userNotification'; +import { + userActivityWebhook, +} from '../../libs/webhook'; import schema from './schema'; @@ -241,41 +244,73 @@ schema.pre('save', true, function preSaveUser (next, done) { // this.items.pets['JackOLantern-Base'] = 5; } - // Manage unallocated stats points notifications - if (this.isDirectSelected('stats') && this.isDirectSelected('notifications') && this.isDirectSelected('flags') && this.isDirectSelected('preferences')) { + // Filter notifications, remove unvalid and not necessary, handle the ones that have special requirements + if ( // Make sure all the data is loaded + this.isDirectSelected('notifications') && + this.isDirectSelected('webhooks') && + this.isDirectSelected('stats') && + this.isDirectSelected('flags') && + this.isDirectSelected('preferences') + ) { + const lvlUpNotifications = []; + const unallocatedPointsNotifications = []; + + this.notifications = this.notifications.filter(notification => { + // Remove corrupt notifications + if (!notification || !notification.type) return false; + + // Remove level up notifications, as they're only used to send webhooks + // Sometimes there can be more than 1 notification + if (notification && notification.type === 'LEVELED_UP') { + lvlUpNotifications.push(notification); + return false; + } + + // Remove all unsallocated stats points + if (notification && notification.type === 'UNALLOCATED_STATS_POINTS') { + unallocatedPointsNotifications.push(notification); + return false; + } + // Keep all the others + return true; + }); + + + // Send lvl up notifications + if (lvlUpNotifications.length > 0) { + const firstLvlNotification = lvlUpNotifications[0]; + const lastLvlNotification = lvlUpNotifications[lvlUpNotifications.length - 1]; + + const initialLvl = firstLvlNotification.data.initialLvl; + const finalLvl = lastLvlNotification.data.newLvl; + + // Delayed so we don't block the user saving + setTimeout(() => { + userActivityWebhook.send(this, { + type: 'leveledUp', + initialLvl, + finalLvl, + }); + }, 50); + } + + // Handle unallocated stats points notifications (keep only one and up to date) const pointsToAllocate = this.stats.points; const classNotEnabled = !this.flags.classSelected || this.preferences.disableClasses; - // Sometimes there can be more than 1 notification - const existingNotifications = this.notifications.filter(notification => { - return notification && notification.type === 'UNALLOCATED_STATS_POINTS'; - }); - - const existingNotificationsLength = existingNotifications.length; // Take the most recent notification - const lastExistingNotification = existingNotificationsLength > 0 ? existingNotifications[existingNotificationsLength - 1] : null; + const lastExistingNotification = unallocatedPointsNotifications[unallocatedPointsNotifications.length - 1]; + // Decide if it's outdated or not const outdatedNotification = !lastExistingNotification || lastExistingNotification.data.points !== pointsToAllocate; - // If the notification is outdated, remove all the existing notifications, otherwise all of them except the last - let notificationsToRemove = outdatedNotification ? existingNotificationsLength : existingNotificationsLength - 1; - // If there are points to allocate and the notification is outdated, add a new notifications - if (pointsToAllocate > 0 && !classNotEnabled && outdatedNotification) { - this.addNotification('UNALLOCATED_STATS_POINTS', { points: pointsToAllocate }); - } - - // Remove the outdated notifications - if (notificationsToRemove > 0) { - let notificationsRemoved = 0; - - this.notifications = this.notifications.filter(notification => { - if (notification && notification.type !== 'UNALLOCATED_STATS_POINTS') return true; - if (notificationsRemoved === notificationsToRemove) return true; - - notificationsRemoved++; - return false; - }); + if (pointsToAllocate > 0 && !classNotEnabled) { + if (outdatedNotification) { + this.addNotification('UNALLOCATED_STATS_POINTS', { points: pointsToAllocate }); + } else { // otherwise add back the last one + this.notifications.push(lastExistingNotification); + } } } diff --git a/website/server/models/userNotification.js b/website/server/models/userNotification.js index 4ddc5c6883..5f0ac2c09f 100644 --- a/website/server/models/userNotification.js +++ b/website/server/models/userNotification.js @@ -29,6 +29,7 @@ const NOTIFICATION_TYPES = [ 'NEW_INBOX_MESSAGE', 'NEW_STUFF', 'NEW_CHAT_MESSAGE', + 'LEVELED_UP', ]; const Schema = mongoose.Schema; diff --git a/website/server/models/webhook.js b/website/server/models/webhook.js index 7398592080..b1ed0a94d3 100644 --- a/website/server/models/webhook.js +++ b/website/server/models/webhook.js @@ -14,9 +14,21 @@ const TASK_ACTIVITY_DEFAULT_OPTIONS = Object.freeze({ created: false, updated: false, deleted: false, + checklistScored: false, scored: true, }); +const USER_ACTIVITY_DEFAULT_OPTIONS = Object.freeze({ + petHatched: false, + mountRaised: false, + leveledUp: false, +}); + +const QUEST_ACTIVITY_DEFAULT_OPTIONS = Object.freeze({ + questStarted: false, + questFinished: false, +}); + export let schema = new Schema({ id: { type: String, @@ -27,7 +39,11 @@ export let schema = new Schema({ type: { type: String, required: true, - enum: ['taskActivity', 'groupChatReceived'], + enum: [ + 'globalActivity', // global webhooks send a request for every type of event + 'taskActivity', 'groupChatReceived', + 'userActivity', 'questActivity', + ], default: 'taskActivity', }, label: { @@ -67,7 +83,7 @@ schema.plugin(baseModel, { schema.methods.formatOptions = function formatOptions (res) { if (this.type === 'taskActivity') { _.defaults(this.options, TASK_ACTIVITY_DEFAULT_OPTIONS); - this.options = _.pick(this.options, 'created', 'updated', 'deleted', 'scored'); + this.options = _.pick(this.options, Object.keys(TASK_ACTIVITY_DEFAULT_OPTIONS)); let invalidOption = Object.keys(this.options) .find(option => typeof this.options[option] !== 'boolean'); @@ -81,6 +97,29 @@ schema.methods.formatOptions = function formatOptions (res) { if (!validator.isUUID(String(this.options.groupId))) { throw new BadRequest(res.t('groupIdRequired')); } + } else if (this.type === 'userActivity') { + _.defaults(this.options, USER_ACTIVITY_DEFAULT_OPTIONS); + this.options = _.pick(this.options, Object.keys(USER_ACTIVITY_DEFAULT_OPTIONS)); + + let invalidOption = Object.keys(this.options) + .find(option => typeof this.options[option] !== 'boolean'); + + if (invalidOption) { + throw new BadRequest(res.t('webhookBooleanOption', { option: invalidOption })); + } + } else if (this.type === 'questActivity') { + _.defaults(this.options, QUEST_ACTIVITY_DEFAULT_OPTIONS); + this.options = _.pick(this.options, Object.keys(QUEST_ACTIVITY_DEFAULT_OPTIONS)); + + let invalidOption = Object.keys(this.options) + .find(option => typeof this.options[option] !== 'boolean'); + + if (invalidOption) { + throw new BadRequest(res.t('webhookBooleanOption', { option: invalidOption })); + } + } else { + // Discard all options + this.options = {}; } };